@memtensor/memos-local-openclaw-plugin 1.0.4-beta.7 → 1.0.4-beta.9

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 (63) hide show
  1. package/README.md +1 -1
  2. package/dist/capture/index.d.ts.map +1 -1
  3. package/dist/capture/index.js +6 -0
  4. package/dist/capture/index.js.map +1 -1
  5. package/dist/client/connector.d.ts.map +1 -1
  6. package/dist/client/connector.js +61 -7
  7. package/dist/client/connector.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +0 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/hub/server.d.ts +7 -0
  12. package/dist/hub/server.d.ts.map +1 -1
  13. package/dist/hub/server.js +171 -8
  14. package/dist/hub/server.js.map +1 -1
  15. package/dist/ingest/providers/index.d.ts.map +1 -1
  16. package/dist/ingest/providers/index.js +37 -6
  17. package/dist/ingest/providers/index.js.map +1 -1
  18. package/dist/recall/engine.d.ts.map +1 -1
  19. package/dist/recall/engine.js +78 -1
  20. package/dist/recall/engine.js.map +1 -1
  21. package/dist/shared/llm-call.d.ts +1 -0
  22. package/dist/shared/llm-call.d.ts.map +1 -1
  23. package/dist/shared/llm-call.js +82 -8
  24. package/dist/shared/llm-call.js.map +1 -1
  25. package/dist/skill/evolver.d.ts +2 -0
  26. package/dist/skill/evolver.d.ts.map +1 -1
  27. package/dist/skill/evolver.js +3 -0
  28. package/dist/skill/evolver.js.map +1 -1
  29. package/dist/storage/sqlite.d.ts +5 -1
  30. package/dist/storage/sqlite.d.ts.map +1 -1
  31. package/dist/storage/sqlite.js +13 -4
  32. package/dist/storage/sqlite.js.map +1 -1
  33. package/dist/telemetry.d.ts +12 -5
  34. package/dist/telemetry.d.ts.map +1 -1
  35. package/dist/telemetry.js +135 -38
  36. package/dist/telemetry.js.map +1 -1
  37. package/dist/types.d.ts +1 -2
  38. package/dist/types.d.ts.map +1 -1
  39. package/dist/types.js.map +1 -1
  40. package/dist/viewer/html.d.ts.map +1 -1
  41. package/dist/viewer/html.js +735 -285
  42. package/dist/viewer/html.js.map +1 -1
  43. package/dist/viewer/server.d.ts +16 -0
  44. package/dist/viewer/server.d.ts.map +1 -1
  45. package/dist/viewer/server.js +349 -21
  46. package/dist/viewer/server.js.map +1 -1
  47. package/index.ts +26 -2
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +1 -2
  50. package/scripts/postinstall.cjs +1 -1
  51. package/src/capture/index.ts +8 -0
  52. package/src/client/connector.ts +62 -7
  53. package/src/config.ts +0 -2
  54. package/src/hub/server.ts +168 -8
  55. package/src/ingest/providers/index.ts +41 -7
  56. package/src/recall/engine.ts +73 -1
  57. package/src/shared/llm-call.ts +97 -9
  58. package/src/skill/evolver.ts +5 -0
  59. package/src/storage/sqlite.ts +19 -6
  60. package/src/telemetry.ts +152 -39
  61. package/src/types.ts +1 -2
  62. package/src/viewer/html.ts +735 -285
  63. package/src/viewer/server.ts +322 -21
@@ -74,10 +74,49 @@ export class RecallEngine {
74
74
  score: 1 / (i + 1),
75
75
  }));
76
76
 
77
+ // Step 1c: Hub memories search (when sharing is enabled and hub_memories exist)
78
+ let hubMemFtsRanked: Array<{ id: string; score: number }> = [];
79
+ let hubMemVecRanked: Array<{ id: string; score: number }> = [];
80
+ if (query && this.ctx.config.sharing?.enabled) {
81
+ try {
82
+ const hubFtsHits = this.store.searchHubMemories(query, { maxResults: candidatePool });
83
+ hubMemFtsRanked = hubFtsHits.map(({ hit }, i) => ({
84
+ id: `hubmem:${hit.id}`, score: 1 / (i + 1),
85
+ }));
86
+ } catch { /* hub_memories table may not exist */ }
87
+ try {
88
+ const hubMemEmbs = this.store.getVisibleHubMemoryEmbeddings("");
89
+ if (hubMemEmbs.length > 0) {
90
+ const qv = await this.embedder.embedQuery(query).catch(() => null);
91
+ if (qv) {
92
+ const scored: Array<{ id: string; score: number }> = [];
93
+ for (const e of hubMemEmbs) {
94
+ let dot = 0, nA = 0, nB = 0;
95
+ for (let i = 0; i < qv.length && i < e.vector.length; i++) {
96
+ dot += qv[i] * e.vector[i]; nA += qv[i] * qv[i]; nB += e.vector[i] * e.vector[i];
97
+ }
98
+ const sim = nA > 0 && nB > 0 ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
99
+ if (sim > 0.3) {
100
+ scored.push({ id: `hubmem:${e.memoryId}`, score: sim });
101
+ }
102
+ }
103
+ scored.sort((a, b) => b.score - a.score);
104
+ hubMemVecRanked = scored.slice(0, candidatePool);
105
+ }
106
+ }
107
+ } catch { /* best-effort */ }
108
+ if (hubMemFtsRanked.length > 0 || hubMemVecRanked.length > 0) {
109
+ this.ctx.log.debug(`recall: hub_memories candidates: fts=${hubMemFtsRanked.length}, vec=${hubMemVecRanked.length}`);
110
+ }
111
+ }
112
+
77
113
  // Step 2: RRF fusion
78
114
  const ftsRanked = ftsCandidates.map((c) => ({ id: c.chunkId, score: c.score }));
79
115
  const vecRanked = vecCandidates.map((c) => ({ id: c.chunkId, score: c.score }));
80
- const rrfScores = rrfFuse([ftsRanked, vecRanked, patternRanked], recallCfg.rrfK);
116
+ const allRankedLists = [ftsRanked, vecRanked, patternRanked];
117
+ if (hubMemFtsRanked.length > 0) allRankedLists.push(hubMemFtsRanked);
118
+ if (hubMemVecRanked.length > 0) allRankedLists.push(hubMemVecRanked);
119
+ const rrfScores = rrfFuse(allRankedLists, recallCfg.rrfK);
81
120
 
82
121
  if (rrfScores.size === 0) {
83
122
  this.recordQuery(query, maxResults, minScore, 0);
@@ -101,6 +140,11 @@ export class RecallEngine {
101
140
 
102
141
  // Step 4: Time decay
103
142
  const withTs = mmrResults.map((r) => {
143
+ if (r.id.startsWith("hubmem:")) {
144
+ const memId = r.id.slice(7);
145
+ const mem = this.store.getHubMemoryById(memId);
146
+ return { ...r, createdAt: mem?.createdAt ?? 0 };
147
+ }
104
148
  const chunk = this.store.getChunk(r.id);
105
149
  return { ...r, createdAt: chunk?.createdAt ?? 0 };
106
150
  });
@@ -128,6 +172,34 @@ export class RecallEngine {
128
172
  const hits: SearchHit[] = [];
129
173
  for (const candidate of normalized) {
130
174
  if (hits.length >= maxResults) break;
175
+
176
+ if (candidate.id.startsWith("hubmem:")) {
177
+ const memId = candidate.id.slice(7);
178
+ const mem = this.store.getHubMemoryById(memId);
179
+ if (!mem) continue;
180
+ if (roleFilter && mem.role !== roleFilter) continue;
181
+ hits.push({
182
+ summary: mem.summary || mem.content.slice(0, 200),
183
+ original_excerpt: mem.content,
184
+ ref: {
185
+ sessionKey: `hub-shared:${mem.sourceUserId}`,
186
+ chunkId: mem.id,
187
+ turnId: "",
188
+ seq: 0,
189
+ },
190
+ score: Math.round(candidate.score * 1000) / 1000,
191
+ taskId: null,
192
+ skillId: null,
193
+ owner: `hub-user:${mem.sourceUserId}`,
194
+ source: {
195
+ ts: mem.createdAt,
196
+ role: (mem.role || "assistant") as any,
197
+ sessionKey: `hub-shared:${mem.sourceUserId}`,
198
+ },
199
+ });
200
+ continue;
201
+ }
202
+
131
203
  const chunk = this.store.getChunk(candidate.id);
132
204
  if (!chunk) continue;
133
205
  if (roleFilter && chunk.role !== roleFilter) continue;
@@ -1,6 +1,34 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
- import type { SummarizerConfig, Logger, PluginContext, OpenClawAPI } from "../types";
3
+ import type { SummarizerConfig, SummaryProvider, Logger, PluginContext, OpenClawAPI } from "../types";
4
+
5
+ /**
6
+ * Detect provider type from provider key name or base URL.
7
+ */
8
+ function detectProvider(providerKey: string | undefined, baseUrl: string): SummaryProvider {
9
+ const key = providerKey?.toLowerCase() ?? "";
10
+ const url = baseUrl.toLowerCase();
11
+ if (key.includes("anthropic") || url.includes("anthropic")) return "anthropic";
12
+ if (key.includes("gemini") || url.includes("generativelanguage.googleapis.com")) {
13
+ return "gemini";
14
+ }
15
+ if (key.includes("bedrock") || url.includes("bedrock")) return "bedrock";
16
+ return "openai_compatible";
17
+ }
18
+
19
+ /**
20
+ * Return the correct default endpoint for a given provider.
21
+ */
22
+ function defaultEndpointForProvider(provider: SummaryProvider, baseUrl: string): string {
23
+ const stripped = baseUrl.replace(/\/+$/, "");
24
+ if (provider === "anthropic") {
25
+ if (stripped.endsWith("/v1/messages")) return stripped;
26
+ return `${stripped}/v1/messages`;
27
+ }
28
+ if (stripped.endsWith("/chat/completions")) return stripped;
29
+ if (stripped.endsWith("/completions")) return stripped;
30
+ return `${stripped}/chat/completions`;
31
+ }
4
32
 
5
33
  /**
6
34
  * Build a SummarizerConfig from OpenClaw's native model configuration (openclaw.json).
@@ -30,13 +58,12 @@ export function loadOpenClawFallbackConfig(log: Logger): SummarizerConfig | unde
30
58
  const apiKey: string | undefined = providerCfg.apiKey;
31
59
  if (!baseUrl || !apiKey) return undefined;
32
60
 
33
- const endpoint = baseUrl.endsWith("/chat/completions")
34
- ? baseUrl
35
- : baseUrl.replace(/\/+$/, "") + "/chat/completions";
61
+ const provider = detectProvider(providerKey, baseUrl);
62
+ const endpoint = defaultEndpointForProvider(provider, baseUrl);
36
63
 
37
- log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl}`);
64
+ log.debug(`OpenClaw fallback model: ${modelId} via ${baseUrl} (${provider})`);
38
65
  return {
39
- provider: "openai_compatible",
66
+ provider,
40
67
  endpoint,
41
68
  apiKey,
42
69
  model: modelId,
@@ -70,23 +97,34 @@ export interface LLMCallOptions {
70
97
  openclawAPI?: OpenClawAPI;
71
98
  }
72
99
 
73
- function normalizeEndpoint(url: string): string {
100
+ function normalizeOpenAIEndpoint(url: string): string {
74
101
  const stripped = url.replace(/\/+$/, "");
75
102
  if (stripped.endsWith("/chat/completions")) return stripped;
76
103
  if (stripped.endsWith("/completions")) return stripped;
77
104
  return `${stripped}/chat/completions`;
78
105
  }
79
106
 
107
+ function normalizeAnthropicEndpoint(url: string): string {
108
+ const stripped = url.replace(/\/+$/, "");
109
+ if (stripped.endsWith("/v1/messages")) return stripped;
110
+ if (stripped.endsWith("/messages")) return stripped;
111
+ return `${stripped}/v1/messages`;
112
+ }
113
+
114
+ function isAnthropicProvider(cfg: SummarizerConfig): boolean {
115
+ return cfg.provider === "anthropic";
116
+ }
117
+
80
118
  /**
81
119
  * Make a single LLM call with the given config. Throws on failure.
82
120
  * When cfg.provider === "openclaw", delegates to the OpenClaw host completion API.
121
+ * Dispatches to Anthropic or OpenAI-compatible format based on provider.
83
122
  */
84
123
  export async function callLLMOnce(
85
124
  cfg: SummarizerConfig,
86
125
  prompt: string,
87
126
  opts: LLMCallOptions = {},
88
127
  ): Promise<string> {
89
- // Handle openclaw provider via host completion API
90
128
  if (cfg.provider === "openclaw") {
91
129
  const api = opts.openclawAPI;
92
130
  if (!api) {
@@ -101,7 +139,57 @@ export async function callLLMOnce(
101
139
  return response.text.trim();
102
140
  }
103
141
 
104
- const endpoint = normalizeEndpoint(cfg.endpoint ?? "https://api.openai.com/v1/chat/completions");
142
+ if (isAnthropicProvider(cfg)) {
143
+ return callLLMOnceAnthropic(cfg, prompt, opts);
144
+ }
145
+ return callLLMOnceOpenAI(cfg, prompt, opts);
146
+ }
147
+
148
+ async function callLLMOnceAnthropic(
149
+ cfg: SummarizerConfig,
150
+ prompt: string,
151
+ opts: LLMCallOptions = {},
152
+ ): Promise<string> {
153
+ const endpoint = normalizeAnthropicEndpoint(
154
+ cfg.endpoint ?? "https://api.anthropic.com/v1/messages",
155
+ );
156
+ const model = cfg.model ?? "claude-3-haiku-20240307";
157
+ const headers: Record<string, string> = {
158
+ "Content-Type": "application/json",
159
+ "x-api-key": cfg.apiKey ?? "",
160
+ "anthropic-version": "2023-06-01",
161
+ ...cfg.headers,
162
+ };
163
+
164
+ const resp = await fetch(endpoint, {
165
+ method: "POST",
166
+ headers,
167
+ body: JSON.stringify({
168
+ model,
169
+ temperature: opts.temperature ?? 0.1,
170
+ max_tokens: opts.maxTokens ?? 1024,
171
+ messages: [{ role: "user", content: prompt }],
172
+ }),
173
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 30_000),
174
+ });
175
+
176
+ if (!resp.ok) {
177
+ const body = await resp.text();
178
+ throw new Error(`LLM call failed (${resp.status}): ${body}`);
179
+ }
180
+
181
+ const json = (await resp.json()) as { content: Array<{ type: string; text: string }> };
182
+ return json.content.find((c) => c.type === "text")?.text?.trim() ?? "";
183
+ }
184
+
185
+ async function callLLMOnceOpenAI(
186
+ cfg: SummarizerConfig,
187
+ prompt: string,
188
+ opts: LLMCallOptions = {},
189
+ ): Promise<string> {
190
+ const endpoint = normalizeOpenAIEndpoint(
191
+ cfg.endpoint ?? "https://api.openai.com/v1/chat/completions",
192
+ );
105
193
  const model = cfg.model ?? "gpt-4o-mini";
106
194
  const headers: Record<string, string> = {
107
195
  "Content-Type": "application/json",
@@ -12,6 +12,8 @@ import { SkillUpgrader } from "./upgrader";
12
12
  import { SkillInstaller } from "./installer";
13
13
  import { buildSkillConfigChain, callLLMWithFallback } from "../shared/llm-call";
14
14
 
15
+ export type SkillEvolvedCallback = (skillName: string, upgradeType: "created" | "upgraded") => void;
16
+
15
17
  export class SkillEvolver {
16
18
  private evaluator: SkillEvaluator;
17
19
  private generator: SkillGenerator;
@@ -19,6 +21,7 @@ export class SkillEvolver {
19
21
  private installer: SkillInstaller;
20
22
  private processing = false;
21
23
  private queue: Task[] = [];
24
+ onSkillEvolved: SkillEvolvedCallback | null = null;
22
25
 
23
26
  constructor(
24
27
  private store: SqliteStore,
@@ -279,6 +282,7 @@ Use selectedIndex 0 when none is highly relevant.`;
279
282
  if (upgraded) {
280
283
  this.store.linkTaskSkill(task.id, freshSkill.id, "evolved_from", freshSkill.version + 1);
281
284
  this.installer.syncIfInstalled(freshSkill.name);
285
+ this.onSkillEvolved?.(freshSkill.name, "upgraded");
282
286
  } else {
283
287
  this.store.linkTaskSkill(task.id, freshSkill.id, "applied_to", freshSkill.version);
284
288
  }
@@ -307,6 +311,7 @@ Use selectedIndex 0 when none is highly relevant.`;
307
311
  this.markChunksWithSkill(chunks, skill.id);
308
312
  this.store.linkTaskSkill(task.id, skill.id, "generated_from", 1);
309
313
  this.store.setTaskSkillMeta(task.id, { skillStatus: "generated", skillReason: evalResult.reason });
314
+ this.onSkillEvolved?.(skill.name, "created");
310
315
 
311
316
  const autoInstall = this.ctx.config.skillEvolution?.autoInstall ?? DEFAULTS.skillAutoInstall;
312
317
  if (autoInstall && skill.status === "active") {
@@ -1818,8 +1818,8 @@ export class SqliteStore {
1818
1818
  return result.changes > 0;
1819
1819
  }
1820
1820
 
1821
- updateHubUserActivity(userId: string, ip: string): void {
1822
- this.db.prepare('UPDATE hub_users SET last_ip = ?, last_active_at = ? WHERE id = ?').run(ip, Date.now(), userId);
1821
+ updateHubUserActivity(userId: string, ip: string, timestamp?: number): void {
1822
+ this.db.prepare('UPDATE hub_users SET last_ip = ?, last_active_at = ? WHERE id = ?').run(ip, timestamp ?? Date.now(), userId);
1823
1823
  }
1824
1824
 
1825
1825
  getHubUserContributions(): Record<string, { memoryCount: number; taskCount: number; skillCount: number }> {
@@ -2063,6 +2063,11 @@ export class SqliteStore {
2063
2063
  }));
2064
2064
  }
2065
2065
 
2066
+ listHubChunksByTaskId(hubTaskId: string): HubChunkRecord[] {
2067
+ const rows = this.db.prepare('SELECT * FROM hub_chunks WHERE hub_task_id = ? ORDER BY created_at ASC').all(hubTaskId) as HubChunkRow[];
2068
+ return rows.map(rowToHubChunk);
2069
+ }
2070
+
2066
2071
  deleteHubTaskById(taskId: string): boolean {
2067
2072
  const info = this.db.prepare('DELETE FROM hub_tasks WHERE id = ?').run(taskId);
2068
2073
  return info.changes > 0;
@@ -2151,6 +2156,14 @@ export class SqliteStore {
2151
2156
  ).run(n.id, n.userId, n.type, n.resource, n.title, n.message ?? '', Date.now());
2152
2157
  }
2153
2158
 
2159
+ hasRecentHubNotification(userId: string, type: string, resource: string, windowMs: number = 300_000): boolean {
2160
+ const since = Date.now() - windowMs;
2161
+ const row = this.db.prepare(
2162
+ 'SELECT COUNT(*) AS cnt FROM hub_notifications WHERE user_id = ? AND type = ? AND resource = ? AND created_at > ?'
2163
+ ).get(userId, type, resource, since) as { cnt: number };
2164
+ return row.cnt > 0;
2165
+ }
2166
+
2154
2167
  listHubNotifications(userId: string, opts?: { unreadOnly?: boolean; limit?: number }): Array<{ id: string; userId: string; type: string; resource: string; title: string; message: string; read: boolean; createdAt: number }> {
2155
2168
  const where = opts?.unreadOnly ? 'WHERE user_id = ? AND read = 0' : 'WHERE user_id = ?';
2156
2169
  const limit = opts?.limit ?? 50;
@@ -2233,7 +2246,7 @@ export class SqliteStore {
2233
2246
  return row ?? null;
2234
2247
  }
2235
2248
 
2236
- listVisibleHubMemories(userId: string, limit = 40): Array<{ id: string; sourceChunkId: string; sourceUserId: string; role: string; summary: string; kind: string; groupId: string | null; groupName: string | null; visibility: string; ownerName: string; createdAt: number; updatedAt: number }> {
2249
+ listVisibleHubMemories(userId: string, limit = 40): Array<{ id: string; sourceChunkId: string; sourceUserId: string; role: string; content: string; summary: string; kind: string; groupId: string | null; groupName: string | null; visibility: string; ownerName: string; createdAt: number; updatedAt: number }> {
2237
2250
  const rows = this.db.prepare(`
2238
2251
  SELECT m.*, u.username AS owner_name, NULL AS group_name
2239
2252
  FROM hub_memories m
@@ -2243,13 +2256,13 @@ export class SqliteStore {
2243
2256
  `).all(limit) as any[];
2244
2257
  return rows.map(r => ({
2245
2258
  id: r.id, sourceChunkId: r.source_chunk_id, sourceUserId: r.source_user_id,
2246
- role: r.role, summary: r.summary, kind: r.kind,
2259
+ role: r.role, content: r.content ?? "", summary: r.summary, kind: r.kind,
2247
2260
  groupId: r.group_id, groupName: r.group_name ?? null, visibility: r.visibility,
2248
2261
  ownerName: r.owner_name ?? "unknown", createdAt: r.created_at, updatedAt: r.updated_at,
2249
2262
  }));
2250
2263
  }
2251
2264
 
2252
- listAllHubMemories(): Array<{ id: string; sourceChunkId: string; sourceUserId: string; role: string; summary: string; kind: string; groupId: string | null; groupName: string | null; visibility: string; ownerName: string; createdAt: number; updatedAt: number }> {
2265
+ listAllHubMemories(): Array<{ id: string; sourceChunkId: string; sourceUserId: string; role: string; content: string; summary: string; kind: string; groupId: string | null; groupName: string | null; visibility: string; ownerName: string; createdAt: number; updatedAt: number }> {
2253
2266
  const rows = this.db.prepare(`
2254
2267
  SELECT m.*, u.username AS owner_name
2255
2268
  FROM hub_memories m
@@ -2258,7 +2271,7 @@ export class SqliteStore {
2258
2271
  `).all() as any[];
2259
2272
  return rows.map(r => ({
2260
2273
  id: r.id, sourceChunkId: r.source_chunk_id, sourceUserId: r.source_user_id,
2261
- role: r.role, summary: r.summary, kind: r.kind,
2274
+ role: r.role, content: r.content ?? "", summary: r.summary, kind: r.kind,
2262
2275
  groupId: r.group_id, groupName: null as string | null, visibility: r.visibility,
2263
2276
  ownerName: r.owner_name ?? "unknown", createdAt: r.created_at, updatedAt: r.updated_at,
2264
2277
  }));
package/src/telemetry.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Telemetry module — anonymous usage analytics via PostHog.
2
+ * Telemetry module — anonymous usage analytics via Aliyun ARMS RUM.
3
3
  *
4
4
  * Privacy-first design:
5
5
  * - Enabled by default with anonymous data only; opt-out via TELEMETRY_ENABLED=false
@@ -8,7 +8,6 @@
8
8
  * - Only sends aggregate counts, tool names, latencies, and version info
9
9
  */
10
10
 
11
- import { PostHog } from "posthog-node";
12
11
  import * as fs from "fs";
13
12
  import * as path from "path";
14
13
  import * as os from "os";
@@ -17,45 +16,61 @@ import type { Logger } from "./types";
17
16
 
18
17
  export interface TelemetryConfig {
19
18
  enabled?: boolean;
20
- posthogApiKey?: string;
21
- posthogHost?: string;
22
19
  }
23
20
 
24
- const DEFAULT_POSTHOG_API_KEY = "phc_7lae6UC5jyImDefX6uub7zCxWyswCGNoBifCKqjvDrI";
25
- const DEFAULT_POSTHOG_HOST = "https://eu.i.posthog.com";
21
+ const ARMS_ENDPOINT =
22
+ "https://proj-xtrace-e218d9316b328f196a3c640cc7ca84-cn-hangzhou.cn-hangzhou.log.aliyuncs.com" +
23
+ "/rum/web/v2" +
24
+ "?workspace=default-cms-1026429231103299-cn-hangzhou" +
25
+ "&service_id=a3u72ukxmr@066657d42a13a9a9f337f";
26
+
27
+ const ARMS_PID = "a3u72ukxmr@066657d42a13a9a9f337f";
28
+ const ARMS_ENV = "prod";
29
+
30
+ const FLUSH_AT = 10;
31
+ const FLUSH_INTERVAL_MS = 30_000;
32
+ const SEND_TIMEOUT_MS = 30_000;
33
+ const SESSION_TTL_MS = 30 * 60_000; // 30 min inactivity → new session
34
+ interface ArmsEvent {
35
+ event_type: "custom";
36
+ type: string;
37
+ name: string;
38
+ group: string;
39
+ value: number;
40
+ properties: Record<string, string | number | boolean>;
41
+ timestamp: number;
42
+ event_id: string;
43
+ times: number;
44
+ }
26
45
 
27
46
  export class Telemetry {
28
- private client: PostHog | null = null;
29
47
  private distinctId: string;
30
48
  private enabled: boolean;
31
49
  private pluginVersion: string;
32
50
  private log: Logger;
33
51
  private dailyPingSent = false;
34
52
  private dailyPingDate = "";
53
+ private buffer: ArmsEvent[] = [];
54
+ private flushTimer: ReturnType<typeof setInterval> | null = null;
55
+ private sessionId: string;
56
+ private firstSeenDate: string;
35
57
 
36
58
  constructor(config: TelemetryConfig, stateDir: string, pluginVersion: string, log: Logger) {
37
59
  this.log = log;
38
60
  this.pluginVersion = pluginVersion;
39
61
  this.enabled = config.enabled !== false;
40
62
  this.distinctId = this.loadOrCreateAnonymousId(stateDir);
63
+ this.firstSeenDate = this.loadOrCreateFirstSeen(stateDir);
64
+ this.sessionId = this.loadOrCreateSessionId(stateDir);
41
65
 
42
66
  if (!this.enabled) {
43
67
  this.log.debug("Telemetry disabled (opt-out via TELEMETRY_ENABLED=false)");
44
68
  return;
45
69
  }
46
70
 
47
- const apiKey = config.posthogApiKey || DEFAULT_POSTHOG_API_KEY;
48
- try {
49
- this.client = new PostHog(apiKey, {
50
- host: config.posthogHost || DEFAULT_POSTHOG_HOST,
51
- flushAt: 10,
52
- flushInterval: 30_000,
53
- });
54
- this.log.debug("Telemetry initialized (PostHog)");
55
- } catch (err) {
56
- this.log.warn(`Telemetry init failed: ${err}`);
57
- this.enabled = false;
58
- }
71
+ this.flushTimer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
72
+ if (this.flushTimer.unref) this.flushTimer.unref();
73
+ this.log.debug("Telemetry initialized (ARMS)");
59
74
  }
60
75
 
61
76
  private loadOrCreateAnonymousId(stateDir: string): string {
@@ -81,24 +96,113 @@ export class Telemetry {
81
96
  return newId;
82
97
  }
83
98
 
99
+ private loadOrCreateSessionId(stateDir: string): string {
100
+ const filePath = path.join(stateDir, "memos-local", ".session");
101
+ try {
102
+ const raw = fs.readFileSync(filePath, "utf-8").trim();
103
+ const sep = raw.indexOf("|");
104
+ if (sep > 0) {
105
+ const ts = parseInt(raw.slice(0, sep), 10);
106
+ const id = raw.slice(sep + 1);
107
+ if (id.length > 10 && Date.now() - ts < SESSION_TTL_MS) {
108
+ this.touchSession(filePath, id);
109
+ return id;
110
+ }
111
+ }
112
+ } catch {}
113
+ const newId = uuidv4();
114
+ this.touchSession(filePath, newId);
115
+ return newId;
116
+ }
117
+
118
+ private touchSession(filePath: string, id: string): void {
119
+ try {
120
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
121
+ fs.writeFileSync(filePath, `${Date.now()}|${id}`, "utf-8");
122
+ } catch {}
123
+ }
124
+
125
+ private loadOrCreateFirstSeen(stateDir: string): string {
126
+ const filePath = path.join(stateDir, "memos-local", ".first-seen");
127
+ try {
128
+ const existing = fs.readFileSync(filePath, "utf-8").trim();
129
+ if (existing.length === 10) return existing;
130
+ } catch {}
131
+ const today = new Date().toISOString().slice(0, 10);
132
+ try {
133
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
134
+ fs.writeFileSync(filePath, today, "utf-8");
135
+ } catch {}
136
+ return today;
137
+ }
138
+
84
139
  private capture(event: string, properties?: Record<string, unknown>): void {
85
- if (!this.enabled || !this.client) return;
140
+ if (!this.enabled) return;
141
+
142
+ const safeProps: Record<string, string | number | boolean> = {
143
+ plugin_version: this.pluginVersion,
144
+ os: os.platform(),
145
+ os_version: os.release(),
146
+ node_version: process.version,
147
+ arch: os.arch(),
148
+ };
149
+ if (properties) {
150
+ for (const [k, v] of Object.entries(properties)) {
151
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") {
152
+ safeProps[k] = v;
153
+ }
154
+ }
155
+ }
156
+
157
+ this.buffer.push({
158
+ event_type: "custom",
159
+ type: "memos_plugin",
160
+ name: event,
161
+ group: "memos_local",
162
+ value: 1,
163
+ properties: safeProps,
164
+ timestamp: Date.now(),
165
+ event_id: uuidv4(),
166
+ times: 1,
167
+ });
168
+
169
+ if (this.buffer.length >= FLUSH_AT) {
170
+ this.flush();
171
+ }
172
+ }
173
+
174
+ private buildPayload(events: ArmsEvent[]): Record<string, unknown> {
175
+ return {
176
+ app: {
177
+ id: ARMS_PID,
178
+ env: ARMS_ENV,
179
+ version: this.pluginVersion,
180
+ type: "node",
181
+ },
182
+ user: { id: this.distinctId },
183
+ session: { id: this.sessionId },
184
+ net: {},
185
+ view: { id: "plugin", name: "memos-local-openclaw" },
186
+ events,
187
+ _v: "1.0.0",
188
+ };
189
+ }
190
+
191
+ private async flush(): Promise<void> {
192
+ if (this.buffer.length === 0) return;
193
+ const batch = this.buffer.splice(0);
194
+ const payload = this.buildPayload(batch);
86
195
 
87
196
  try {
88
- this.client.capture({
89
- distinctId: this.distinctId,
90
- event,
91
- properties: {
92
- plugin_version: this.pluginVersion,
93
- os: os.platform(),
94
- os_version: os.release(),
95
- node_version: process.version,
96
- arch: os.arch(),
97
- ...properties,
98
- },
197
+ const resp = await fetch(ARMS_ENDPOINT, {
198
+ method: "POST",
199
+ headers: { "Content-Type": "text/plain" },
200
+ body: JSON.stringify(payload),
201
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
99
202
  });
100
- } catch {
101
- // best-effort, never throw
203
+ this.log.debug(`Telemetry flush: ${batch.length} events → ${resp.status}`);
204
+ } catch (err) {
205
+ this.log.debug(`Telemetry flush failed: ${err}`);
102
206
  }
103
207
  }
104
208
 
@@ -131,7 +235,7 @@ export class Telemetry {
131
235
  });
132
236
  }
133
237
 
134
- trackSkillEvolved(skillName: string, upgradeType: string): void {
238
+ trackSkillEvolved(skillName: string, upgradeType: "created" | "upgraded"): void {
135
239
  this.capture("skill_evolved", {
136
240
  skill_name: skillName,
137
241
  upgrade_type: upgradeType,
@@ -150,19 +254,28 @@ export class Telemetry {
150
254
  });
151
255
  }
152
256
 
257
+ trackError(source: string, errorType: string): void {
258
+ this.capture("plugin_error", {
259
+ error_source: source,
260
+ error_type: errorType,
261
+ });
262
+ }
263
+
153
264
  private maybeSendDailyPing(): void {
154
265
  const today = new Date().toISOString().slice(0, 10);
155
266
  if (this.dailyPingSent && this.dailyPingDate === today) return;
156
267
  this.dailyPingSent = true;
157
268
  this.dailyPingDate = today;
158
- this.capture("daily_active");
269
+ this.capture("daily_active", {
270
+ first_seen_date: this.firstSeenDate,
271
+ });
159
272
  }
160
273
 
161
274
  async shutdown(): Promise<void> {
162
- if (this.client) {
163
- try {
164
- await this.client.shutdown();
165
- } catch {}
275
+ if (this.flushTimer) {
276
+ clearInterval(this.flushTimer);
277
+ this.flushTimer = null;
166
278
  }
279
+ await this.flush();
167
280
  }
168
281
  }
package/src/types.ts CHANGED
@@ -255,8 +255,6 @@ export interface SkillEvolutionConfig {
255
255
 
256
256
  export interface TelemetryConfig {
257
257
  enabled?: boolean;
258
- posthogApiKey?: string;
259
- posthogHost?: string;
260
258
  }
261
259
 
262
260
  export type SharingRole = "hub" | "client";
@@ -277,6 +275,7 @@ export interface ClientModeConfig {
277
275
  hubAddress?: string;
278
276
  userToken?: string;
279
277
  teamToken?: string;
278
+ nickname?: string;
280
279
  pendingUserId?: string;
281
280
  }
282
281