linksee-memory 0.0.2 → 0.0.4

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/README.md CHANGED
@@ -139,6 +139,46 @@ Claude Code ships a built-in memory feature at `~/.claude/projects/<path>/memory
139
139
 
140
140
  Use both.
141
141
 
142
+ ## Telemetry (opt-in, off by default)
143
+
144
+ linksee-memory ships with **opt-in** anonymous telemetry that helps us understand which MCP servers and workflows actually work in the wild. **Nothing is sent unless you explicitly enable it.** No conversation content, no file content, no entity names, no project paths — ever.
145
+
146
+ ### Enable
147
+
148
+ ```bash
149
+ export LINKSEE_TELEMETRY=basic # opt in
150
+ export LINKSEE_TELEMETRY=off # opt out (or just unset the variable)
151
+ ```
152
+
153
+ ### Exactly what gets sent (Level 1 contract)
154
+
155
+ After each Claude Code session ends, the Stop hook sends one POST to `https://kansei-link-mcp-production.up.railway.app/api/telemetry/linksee` containing only these fields:
156
+
157
+ | Field | Example | What it is |
158
+ |---|---|---|
159
+ | `anon_id` | `d7924ced-3879-…` | Random UUID generated locally on first opt-in. Stored at `~/.linksee-memory/telemetry-id` — delete the file to reset. |
160
+ | `linksee_version` | `0.0.3` | Package version |
161
+ | `session_turn_count` | `120` | How many turns the session had |
162
+ | `session_duration_sec` | `3600` | How long the session lasted |
163
+ | `file_ops_edit/write/read` | `12, 2, 40` | Counts only |
164
+ | `mcp_servers` | `["kansei-link","freee","slack"]` | Names of MCP servers configured (from `~/.claude.json`). Names only — never command paths. |
165
+ | `file_extensions` | `{".ts":60,".md":30}` | Percent distribution of file extensions touched |
166
+ | `read_smart_*`, `recall_*` | counts | Tool usage counters |
167
+
168
+ **What is NEVER sent**:
169
+ - ❌ Conversation messages (user or assistant)
170
+ - ❌ File contents
171
+ - ❌ Entity names, project names, file paths, URLs
172
+ - ❌ Memory-layer text (goal / context / emotion / impl / caveat / learning)
173
+ - ❌ Authentication tokens, API keys, secrets
174
+ - ❌ Your IP address (only a one-way hash for abuse detection)
175
+
176
+ ### Why we ask
177
+
178
+ Aggregated MCP-usage data helps the [KanseiLink](https://kansei-link.com) project rank which agent integrations actually work for real developers. If you're happy to contribute, `LINKSEE_TELEMETRY=basic` takes 1 second to set and helps the entire MCP ecosystem improve.
179
+
180
+ The full payload schema and validation logic is open-source — read `src/lib/telemetry.ts` if you want to verify exactly what leaves your machine.
181
+
142
182
  ## License
143
183
 
144
184
  MIT — Synapse Arrows PTE. LTD.
@@ -10,10 +10,12 @@
10
10
  // - MUST be silent on stdout (Claude does not need feedback)
11
11
  // - All errors logged to ~/.linksee-memory/hook.log
12
12
  import { spawnSync } from 'node:child_process';
13
- import { mkdirSync, appendFileSync, existsSync, statSync, renameSync } from 'node:fs';
13
+ import { mkdirSync, appendFileSync, existsSync, statSync, renameSync, readFileSync } from 'node:fs';
14
14
  import { join, dirname } from 'node:path';
15
15
  import { homedir } from 'node:os';
16
16
  import { fileURLToPath } from 'node:url';
17
+ import { isTelemetryEnabled, buildPayload, sendTelemetry } from '../lib/telemetry.js';
18
+ import Database from 'better-sqlite3';
17
19
  const LOG_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
18
20
  const LOG_FILE = join(LOG_DIR, 'hook.log');
19
21
  const LOG_MAX_BYTES = 1024 * 1024; // 1 MB → rotate
@@ -80,13 +82,57 @@ async function main() {
80
82
  const elapsed = Date.now() - startedAt;
81
83
  if (r.error) {
82
84
  log(`importer spawn error (session=${sessionId}, ${elapsed}ms): ${r.error.message}`);
85
+ process.exit(0);
83
86
  }
84
87
  else if (r.status !== 0) {
85
88
  log(`importer exited ${r.status} (session=${sessionId}, ${elapsed}ms): ${(r.stderr || '').slice(0, 500)}`);
89
+ process.exit(0);
86
90
  }
87
- else {
88
- const out = (r.stdout || '').trim().split('\n').slice(-1)[0] || '';
89
- log(`ok (session=${sessionId}, cwd=${cwd}, ${elapsed}ms): ${out}`);
91
+ const out = (r.stdout || '').trim().split('\n').slice(-1)[0] || '';
92
+ log(`ok (session=${sessionId}, cwd=${cwd}, ${elapsed}ms): ${out}`);
93
+ // ── Opt-in telemetry (LINKSEE_TELEMETRY=basic) ──────────────────
94
+ // Runs ONLY if explicitly enabled. Failures are silent. Never blocks Claude Code.
95
+ if (isTelemetryEnabled() && sessionId) {
96
+ try {
97
+ // Detect MCP servers in use from BOTH ~/.claude.json and ~/.claude/settings.json
98
+ // (Claude Code reads both — names only, never commands or arg paths.)
99
+ const mcpServerSet = new Set();
100
+ for (const confPath of [join(homedir(), '.claude.json'), join(homedir(), '.claude', 'settings.json')]) {
101
+ try {
102
+ if (!existsSync(confPath))
103
+ continue;
104
+ const parsed = JSON.parse(readFileSync(confPath, 'utf8'));
105
+ if (parsed && typeof parsed.mcpServers === 'object' && parsed.mcpServers) {
106
+ for (const name of Object.keys(parsed.mcpServers)) {
107
+ mcpServerSet.add(String(name).slice(0, 64));
108
+ }
109
+ }
110
+ }
111
+ catch { /* ignore */ }
112
+ }
113
+ const mcpServers = Array.from(mcpServerSet);
114
+ const dbDir = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
115
+ const dbPath = join(dbDir, 'memory.db');
116
+ if (existsSync(dbPath)) {
117
+ const db = new Database(dbPath, { readonly: true });
118
+ try {
119
+ const payload = buildPayload(db, sessionId, { mcpServersInUse: mcpServers });
120
+ if (payload) {
121
+ const result = await sendTelemetry(payload, { timeoutMs: 3000 });
122
+ if (result.ok)
123
+ log(`telemetry: sent (anon=${payload.anon_id.slice(0, 8)}, mcp=${mcpServers.length}, exts=${Object.keys(payload.file_extensions).length})`);
124
+ else
125
+ log(`telemetry: send failed: ${result.error}`);
126
+ }
127
+ }
128
+ finally {
129
+ db.close();
130
+ }
131
+ }
132
+ }
133
+ catch (e) {
134
+ log(`telemetry: error (non-fatal): ${e?.message ?? e}`);
135
+ }
90
136
  }
91
137
  process.exit(0);
92
138
  }
@@ -0,0 +1,31 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type TelemetryMode = 'off' | 'basic';
3
+ export declare function getTelemetryMode(): TelemetryMode;
4
+ export declare function getOrCreateAnonId(): string;
5
+ interface TelemetryPayload {
6
+ anon_id: string;
7
+ linksee_version: string;
8
+ session_turn_count: number;
9
+ session_duration_sec: number;
10
+ file_ops_edit: number;
11
+ file_ops_write: number;
12
+ file_ops_read: number;
13
+ errors_count: number;
14
+ mcp_servers: string[];
15
+ file_extensions: Record<string, number>;
16
+ read_smart_savings_pct: number | null;
17
+ read_smart_calls: number;
18
+ recall_calls: number;
19
+ recall_file_calls: number;
20
+ }
21
+ export declare function buildPayload(db: Database.Database, sessionId: string, options?: {
22
+ mcpServersInUse?: string[];
23
+ }): TelemetryPayload | null;
24
+ export declare function sendTelemetry(payload: TelemetryPayload, opts?: {
25
+ timeoutMs?: number;
26
+ }): Promise<{
27
+ ok: boolean;
28
+ error?: string;
29
+ }>;
30
+ export declare function isTelemetryEnabled(): boolean;
31
+ export {};
@@ -0,0 +1,143 @@
1
+ // Opt-in, privacy-preserving telemetry for linksee-memory.
2
+ //
3
+ // PRIVACY CONTRACT (also documented in README):
4
+ // - DEFAULT OFF. Activated only when LINKSEE_TELEMETRY=basic.
5
+ // - Sends only Level 1 fields: aggregated counts and signal distributions.
6
+ // - NEVER sends conversation content, user messages, file content,
7
+ // entity names, project paths, or any layer text (goal/context/emotion/
8
+ // impl/caveat/learning content is not included).
9
+ // - Anonymous UUID generated locally on first opt-in; stored at
10
+ // ~/.linksee-memory/telemetry-id. User can delete it any time.
11
+ // - Disable any time: LINKSEE_TELEMETRY=off (or unset the variable).
12
+ import { randomUUID } from 'node:crypto';
13
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
14
+ import { join, extname, join as pathJoin, dirname as pathDirname } from 'node:path';
15
+ import { homedir } from 'node:os';
16
+ import { fileURLToPath } from 'node:url';
17
+ // Production endpoint hosted on Railway. (kansei-link.com is a GitHub Pages site;
18
+ // the dynamic API lives on the Railway deployment.)
19
+ const DEFAULT_ENDPOINT = 'https://kansei-link-mcp-production.up.railway.app/api/telemetry/linksee';
20
+ // Allow override for testing or self-hosting
21
+ const ENDPOINT = process.env.LINKSEE_TELEMETRY_URL || DEFAULT_ENDPOINT;
22
+ const TELEMETRY_DIR = process.env.LINKSEE_MEMORY_DIR ?? join(homedir(), '.linksee-memory');
23
+ const TELEMETRY_ID_FILE = join(TELEMETRY_DIR, 'telemetry-id');
24
+ export function getTelemetryMode() {
25
+ const v = (process.env.LINKSEE_TELEMETRY || '').toLowerCase().trim();
26
+ if (v === 'basic' || v === 'on' || v === '1' || v === 'true')
27
+ return 'basic';
28
+ return 'off';
29
+ }
30
+ export function getOrCreateAnonId() {
31
+ try {
32
+ if (existsSync(TELEMETRY_ID_FILE)) {
33
+ const id = readFileSync(TELEMETRY_ID_FILE, 'utf8').trim();
34
+ if (/^[A-Za-z0-9_-]{8,64}$/.test(id))
35
+ return id;
36
+ }
37
+ }
38
+ catch { /* ignore */ }
39
+ // Generate a fresh one
40
+ const id = randomUUID();
41
+ try {
42
+ mkdirSync(TELEMETRY_DIR, { recursive: true });
43
+ writeFileSync(TELEMETRY_ID_FILE, id);
44
+ }
45
+ catch { /* best-effort */ }
46
+ return id;
47
+ }
48
+ // Read package version from our own package.json (best-effort, ESM-safe)
49
+ function getLinkseeVersion() {
50
+ try {
51
+ const here = fileURLToPath(import.meta.url);
52
+ // dist/lib/telemetry.js → ../../package.json
53
+ const pkgPath = pathJoin(pathDirname(pathDirname(pathDirname(here))), 'package.json');
54
+ if (existsSync(pkgPath)) {
55
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
56
+ return String(pkg.version || 'unknown');
57
+ }
58
+ }
59
+ catch { /* ignore */ }
60
+ return 'unknown';
61
+ }
62
+ // Build a payload from a single just-imported session.
63
+ // All inputs come from session_file_edits + events; nothing reads memories.content.
64
+ export function buildPayload(db, sessionId, options = {}) {
65
+ const eventRow = db.prepare(`SELECT payload, occurred_at FROM events WHERE kind = 'session_imported' AND payload LIKE ? ORDER BY id DESC LIMIT 1`).get(`%"session_id":"${sessionId}"%`);
66
+ // Aggregate file ops
67
+ const opsRows = db.prepare(`SELECT operation, COUNT(*) as c FROM session_file_edits WHERE session_id = ? GROUP BY operation`).all(sessionId);
68
+ const opCounts = { edit: 0, write: 0, read: 0 };
69
+ for (const r of opsRows)
70
+ opCounts[r.operation] = (opCounts[r.operation] || 0) + r.c;
71
+ // File extension distribution (anonymized — just extensions, never paths/names)
72
+ const extRows = db.prepare(`SELECT file_path FROM session_file_edits WHERE session_id = ?`).all(sessionId);
73
+ const extCounts = {};
74
+ for (const r of extRows) {
75
+ const ext = (extname(r.file_path) || '(none)').toLowerCase().slice(0, 12);
76
+ extCounts[ext] = (extCounts[ext] || 0) + 1;
77
+ }
78
+ // Convert to percent distribution, drop low-count extensions to save space
79
+ const total = extRows.length || 1;
80
+ const extPct = {};
81
+ for (const [ext, count] of Object.entries(extCounts)) {
82
+ const pct = Math.round((count / total) * 100);
83
+ if (pct >= 1)
84
+ extPct[ext] = pct;
85
+ }
86
+ // Pull session metadata from the recorded event
87
+ let stats = {};
88
+ let durationSec = 0;
89
+ if (eventRow) {
90
+ try {
91
+ stats = JSON.parse(eventRow.payload).stats || {};
92
+ }
93
+ catch { }
94
+ }
95
+ const turnsTotal = stats.turns_total || 0;
96
+ // session start/end approximation
97
+ const tsRow = db.prepare(`SELECT MIN(occurred_at) as start, MAX(occurred_at) as end FROM session_file_edits WHERE session_id = ?`).get(sessionId);
98
+ if (tsRow && tsRow.start && tsRow.end)
99
+ durationSec = Math.max(0, tsRow.end - tsRow.start);
100
+ return {
101
+ anon_id: getOrCreateAnonId(),
102
+ linksee_version: getLinkseeVersion(),
103
+ session_turn_count: turnsTotal,
104
+ session_duration_sec: durationSec,
105
+ file_ops_edit: opCounts.edit || 0,
106
+ file_ops_write: opCounts.write || 0,
107
+ file_ops_read: opCounts.read || 0,
108
+ errors_count: 0, // session_extractor doesn't surface this currently; safe default
109
+ mcp_servers: (options.mcpServersInUse || []).slice(0, 50),
110
+ file_extensions: extPct,
111
+ read_smart_savings_pct: null, // wired up later when we track this per session
112
+ read_smart_calls: 0,
113
+ recall_calls: 0,
114
+ recall_file_calls: 0,
115
+ };
116
+ }
117
+ // Fire-and-forget POST. Any failure is silent (logged by caller).
118
+ export async function sendTelemetry(payload, opts = {}) {
119
+ const timeoutMs = opts.timeoutMs ?? 3000;
120
+ try {
121
+ const controller = new AbortController();
122
+ const t = setTimeout(() => controller.abort(), timeoutMs);
123
+ const res = await fetch(ENDPOINT, {
124
+ method: 'POST',
125
+ headers: { 'content-type': 'application/json', 'user-agent': `linksee-memory/${payload.linksee_version}` },
126
+ body: JSON.stringify(payload),
127
+ signal: controller.signal,
128
+ });
129
+ clearTimeout(t);
130
+ if (!res.ok) {
131
+ return { ok: false, error: `http_${res.status}` };
132
+ }
133
+ return { ok: true };
134
+ }
135
+ catch (e) {
136
+ return { ok: false, error: String(e?.name || e?.message || e).slice(0, 100) };
137
+ }
138
+ }
139
+ // Convenience: helper for caller to know if it should bother building a payload
140
+ export function isTelemetryEnabled() {
141
+ return getTelemetryMode() !== 'off';
142
+ }
143
+ //# sourceMappingURL=telemetry.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linksee-memory",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Local-first agent memory MCP — cross-agent brain with 6-layer structured memory + token-saving file diff cache",
5
5
  "type": "module",
6
6
  "bin": {