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 +40 -0
- package/dist/bin/sync-session.js +50 -4
- package/dist/lib/telemetry.d.ts +31 -0
- package/dist/lib/telemetry.js +143 -0
- package/package.json +1 -1
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.
|
package/dist/bin/sync-session.js
CHANGED
|
@@ -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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|