persnally 2.0.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 (50) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +96 -0
  3. package/build/src/cli.d.ts +6 -0
  4. package/build/src/cli.js +404 -0
  5. package/build/src/config.d.ts +10 -0
  6. package/build/src/config.js +42 -0
  7. package/build/src/connect.d.ts +13 -0
  8. package/build/src/connect.js +51 -0
  9. package/build/src/consolidate.d.ts +18 -0
  10. package/build/src/consolidate.js +67 -0
  11. package/build/src/daemon.d.ts +9 -0
  12. package/build/src/daemon.js +167 -0
  13. package/build/src/dashboard.html +181 -0
  14. package/build/src/decay.d.ts +19 -0
  15. package/build/src/decay.js +33 -0
  16. package/build/src/events.d.ts +180 -0
  17. package/build/src/events.js +133 -0
  18. package/build/src/importers/chatgpt.d.ts +9 -0
  19. package/build/src/importers/chatgpt.js +34 -0
  20. package/build/src/importers/claude-code.d.ts +16 -0
  21. package/build/src/importers/claude-code.js +99 -0
  22. package/build/src/importers/claude.d.ts +8 -0
  23. package/build/src/importers/claude.js +52 -0
  24. package/build/src/importers/extract.d.ts +31 -0
  25. package/build/src/importers/extract.js +53 -0
  26. package/build/src/importers/git.d.ts +23 -0
  27. package/build/src/importers/git.js +123 -0
  28. package/build/src/lifecycle.d.ts +14 -0
  29. package/build/src/lifecycle.js +119 -0
  30. package/build/src/llm.d.ts +25 -0
  31. package/build/src/llm.js +76 -0
  32. package/build/src/mcp/daemon-client.d.ts +11 -0
  33. package/build/src/mcp/daemon-client.js +42 -0
  34. package/build/src/mcp/index.d.ts +10 -0
  35. package/build/src/mcp/index.js +158 -0
  36. package/build/src/mcp/migrate-v1.d.ts +6 -0
  37. package/build/src/mcp/migrate-v1.js +48 -0
  38. package/build/src/mcp/telemetry.d.ts +8 -0
  39. package/build/src/mcp/telemetry.js +29 -0
  40. package/build/src/paths.d.ts +2 -0
  41. package/build/src/paths.js +4 -0
  42. package/build/src/permissions.d.ts +14 -0
  43. package/build/src/permissions.js +33 -0
  44. package/build/src/profile.d.ts +22 -0
  45. package/build/src/profile.js +62 -0
  46. package/build/src/setup.d.ts +23 -0
  47. package/build/src/setup.js +111 -0
  48. package/build/src/store.d.ts +62 -0
  49. package/build/src/store.js +233 -0
  50. package/package.json +56 -0
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Git history importer — fully deterministic, no LLM, works offline.
3
+ * Repos become project topics; manifest dependencies become skill signals.
4
+ * Carries forward the v1 skill_analyzer's framework-detection approach.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
8
+ import { basename, join } from "node:path";
9
+ import { newEvent, uuidv7 } from "../events.js";
10
+ const FRAMEWORKS = {
11
+ // js/ts
12
+ react: "frontend", next: "frontend", vue: "frontend", svelte: "frontend", "solid-js": "frontend",
13
+ tailwindcss: "frontend", express: "backend", fastify: "backend", hono: "backend", nestjs: "backend",
14
+ "better-sqlite3": "backend", prisma: "backend", drizzle: "backend", zod: "backend",
15
+ "@modelcontextprotocol/sdk": "ai_ml", "@anthropic-ai/sdk": "ai_ml", openai: "ai_ml", langchain: "ai_ml",
16
+ electron: "desktop", "react-native": "mobile", expo: "mobile",
17
+ // python
18
+ fastapi: "backend", django: "backend", flask: "backend", sqlalchemy: "backend", pydantic: "backend",
19
+ torch: "ai_ml", tensorflow: "ai_ml", transformers: "ai_ml", anthropic: "ai_ml", pandas: "data",
20
+ numpy: "data", "scikit-learn": "data",
21
+ // other ecosystems (manifest presence)
22
+ cargo: "systems", "go.mod": "backend",
23
+ };
24
+ /** Pure: manifest filename → content → detected framework names. */
25
+ export function detectFrameworks(manifests) {
26
+ const found = new Set();
27
+ for (const [name, content] of Object.entries(manifests)) {
28
+ if (name === "package.json") {
29
+ try {
30
+ const pkg = JSON.parse(content);
31
+ for (const dep of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
32
+ if (FRAMEWORKS[dep])
33
+ found.add(dep);
34
+ }
35
+ }
36
+ catch { /* unparseable manifest — skip, not fatal */ }
37
+ }
38
+ if (name === "requirements.txt" || name === "pyproject.toml") {
39
+ for (const fw of Object.keys(FRAMEWORKS)) {
40
+ if (new RegExp(`\\b${fw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i").test(content))
41
+ found.add(fw);
42
+ }
43
+ }
44
+ if (name === "Cargo.toml")
45
+ found.add("cargo");
46
+ if (name === "go.mod")
47
+ found.add("go.mod");
48
+ }
49
+ return [...found];
50
+ }
51
+ function git(repoPath, args) {
52
+ return execFileSync("git", ["-C", repoPath, ...args], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
53
+ }
54
+ export function summarizeRepo(repoPath, authorEmail) {
55
+ if (!existsSync(join(repoPath, ".git")))
56
+ return null;
57
+ const author = authorEmail ?? git(repoPath, ["config", "user.email"]);
58
+ if (!author)
59
+ return null;
60
+ const log = git(repoPath, ["log", `--author=${author}`, "--format=%aI", "--no-merges"]);
61
+ if (!log)
62
+ return null;
63
+ const dates = log.split("\n");
64
+ const manifests = {};
65
+ for (const name of ["package.json", "requirements.txt", "pyproject.toml", "Cargo.toml", "go.mod"]) {
66
+ const p = join(repoPath, name);
67
+ if (existsSync(p))
68
+ manifests[name] = readFileSync(p, "utf-8");
69
+ }
70
+ return {
71
+ repo: basename(repoPath),
72
+ path: repoPath,
73
+ commits: dates.length,
74
+ firstCommit: dates[dates.length - 1],
75
+ lastCommit: dates[0],
76
+ frameworks: detectFrameworks(manifests),
77
+ };
78
+ }
79
+ /** A path is either a repo or a directory of repos — resolve to repo summaries. */
80
+ export function scanRepos(path, authorEmail) {
81
+ const direct = summarizeRepo(path, authorEmail);
82
+ if (direct)
83
+ return [direct];
84
+ return readdirSync(path, { withFileTypes: true })
85
+ .filter((d) => d.isDirectory())
86
+ .map((d) => { try {
87
+ return summarizeRepo(join(path, d.name), authorEmail);
88
+ }
89
+ catch {
90
+ return null;
91
+ } })
92
+ .filter((s) => s !== null);
93
+ }
94
+ export function gitEvents(summaries) {
95
+ const batch = uuidv7();
96
+ const events = [];
97
+ const activity = (commits) => Math.min(Math.log2(commits + 1) / 8, 1);
98
+ for (const s of summaries) {
99
+ events.push(newEvent("signal.topic", "import:git", {
100
+ topic: s.repo,
101
+ weight: Math.max(activity(s.commits), 0.1),
102
+ intent: "building",
103
+ sentiment: "neutral",
104
+ depth: s.commits > 50 ? "deep" : s.commits > 10 ? "moderate" : "mention",
105
+ category: "technology",
106
+ entities: s.frameworks.slice(0, 10),
107
+ }, { kind: "git", repo: s.repo, batch }, s.lastCommit));
108
+ for (const fw of s.frameworks) {
109
+ events.push(newEvent("signal.skill", "import:git", {
110
+ skill: fw,
111
+ domain: FRAMEWORKS[fw] ?? "other",
112
+ proficiency: activity(s.commits),
113
+ basis: `repo-activity:${s.repo}`,
114
+ }, { kind: "git", repo: s.repo, batch }, s.lastCommit));
115
+ }
116
+ }
117
+ events.push(newEvent("system.import", "system", {
118
+ importer: "git",
119
+ batch,
120
+ events: events.length,
121
+ }, { kind: "import", batch, file: summaries.map((s) => s.repo).join(",") }));
122
+ return { events, batch };
123
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Daemon lifecycle — pidfile, detached start/stop, and macOS launchd autostart.
3
+ * The pidfile is advisory: a stale one (dead pid) is detected and cleaned up.
4
+ */
5
+ export declare const LOG_FILE: string;
6
+ /** Returns the running daemon's pid, cleaning up a stale pidfile if found. */
7
+ export declare function runningPid(): number | null;
8
+ export declare function writePidFile(): void;
9
+ export declare function removePidFile(): void;
10
+ export declare function startDetached(cliPath: string, port: number): Promise<number>;
11
+ export declare function stopDaemon(): Promise<number | null>;
12
+ export declare function autostartInstalled(): boolean;
13
+ export declare function installAutostart(cliPath: string, port: number): string;
14
+ export declare function removeAutostart(): boolean;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Daemon lifecycle — pidfile, detached start/stop, and macOS launchd autostart.
3
+ * The pidfile is advisory: a stale one (dead pid) is detected and cleaned up.
4
+ */
5
+ import { execFileSync, spawn } from "node:child_process";
6
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { DATA_DIR } from "./paths.js";
10
+ const PID_FILE = join(DATA_DIR, "daemon.pid");
11
+ export const LOG_FILE = join(DATA_DIR, "daemon.log");
12
+ const PLIST_LABEL = "com.persnally.daemon";
13
+ const PLIST_PATH = join(homedir(), "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
14
+ function alive(pid) {
15
+ try {
16
+ process.kill(pid, 0);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ /** Returns the running daemon's pid, cleaning up a stale pidfile if found. */
24
+ export function runningPid() {
25
+ if (!existsSync(PID_FILE))
26
+ return null;
27
+ const pid = Number(readFileSync(PID_FILE, "utf-8").trim());
28
+ if (Number.isInteger(pid) && pid > 0 && alive(pid))
29
+ return pid;
30
+ unlinkSync(PID_FILE);
31
+ return null;
32
+ }
33
+ export function writePidFile() {
34
+ mkdirSync(DATA_DIR, { recursive: true });
35
+ writeFileSync(PID_FILE, String(process.pid));
36
+ }
37
+ export function removePidFile() {
38
+ try {
39
+ if (Number(readFileSync(PID_FILE, "utf-8").trim()) === process.pid)
40
+ unlinkSync(PID_FILE);
41
+ }
42
+ catch { /* already gone */ }
43
+ }
44
+ export async function startDetached(cliPath, port) {
45
+ mkdirSync(DATA_DIR, { recursive: true });
46
+ const log = openSync(LOG_FILE, "a");
47
+ const child = spawn(process.execPath, [cliPath, "serve", "--port", String(port)], {
48
+ detached: true,
49
+ stdio: ["ignore", log, log],
50
+ env: process.env,
51
+ });
52
+ child.unref();
53
+ for (let i = 0; i < 30; i++) {
54
+ await sleep(100);
55
+ try {
56
+ const r = await fetch(`http://127.0.0.1:${port}/health`);
57
+ if (r.ok)
58
+ return child.pid;
59
+ }
60
+ catch { /* not up yet */ }
61
+ }
62
+ throw new Error(`daemon did not become healthy — check ${LOG_FILE}`);
63
+ }
64
+ export async function stopDaemon() {
65
+ const pid = runningPid();
66
+ if (!pid)
67
+ return null;
68
+ process.kill(pid, "SIGTERM");
69
+ for (let i = 0; i < 50; i++) {
70
+ await sleep(100);
71
+ if (!alive(pid))
72
+ return pid;
73
+ }
74
+ throw new Error(`daemon (pid ${pid}) did not exit within 5s`);
75
+ }
76
+ export function autostartInstalled() {
77
+ return existsSync(PLIST_PATH);
78
+ }
79
+ export function installAutostart(cliPath, port) {
80
+ if (process.platform !== "darwin")
81
+ throw new Error("autostart is macOS-only for now (launchd)");
82
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
83
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
84
+ <plist version="1.0">
85
+ <dict>
86
+ <key>Label</key><string>${PLIST_LABEL}</string>
87
+ <key>ProgramArguments</key>
88
+ <array>
89
+ <string>${process.execPath}</string>
90
+ <string>${cliPath}</string>
91
+ <string>serve</string>
92
+ <string>--port</string>
93
+ <string>${port}</string>
94
+ </array>
95
+ <key>RunAtLoad</key><true/>
96
+ <key>KeepAlive</key><true/>
97
+ <key>StandardOutPath</key><string>${LOG_FILE}</string>
98
+ <key>StandardErrorPath</key><string>${LOG_FILE}</string>
99
+ </dict>
100
+ </plist>
101
+ `;
102
+ mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
103
+ writeFileSync(PLIST_PATH, plist);
104
+ execFileSync("launchctl", ["load", "-w", PLIST_PATH]);
105
+ return PLIST_PATH;
106
+ }
107
+ export function removeAutostart() {
108
+ if (!existsSync(PLIST_PATH))
109
+ return false;
110
+ try {
111
+ execFileSync("launchctl", ["unload", "-w", PLIST_PATH]);
112
+ }
113
+ catch { /* not loaded */ }
114
+ rmSync(PLIST_PATH);
115
+ return true;
116
+ }
117
+ function sleep(ms) {
118
+ return new Promise((r) => setTimeout(r, ms));
119
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Structured extraction shared by importers and profile synthesis.
3
+ * Uses output_config structured outputs (works on every current model,
4
+ * including Fable 5 where forced tool_choice is not supported).
5
+ */
6
+ import { z } from "zod";
7
+ export declare const DEFAULT_EXTRACT_MODEL: string;
8
+ export declare const DEFAULT_PROFILE_MODEL: string;
9
+ export type LlmExtract = (opts: {
10
+ model: string;
11
+ instruction: string;
12
+ schema: z.ZodType;
13
+ content: string;
14
+ maxTokens?: number;
15
+ }) => Promise<unknown>;
16
+ /** Default extractor backed by the Anthropic API; injectable for tests. */
17
+ export declare const anthropicExtract: LlmExtract;
18
+ export declare const ollamaExtract: LlmExtract;
19
+ export interface ChosenExtractor {
20
+ extract: LlmExtract;
21
+ model: string;
22
+ label: string;
23
+ }
24
+ /** Anthropic key wins (quality); otherwise local Ollama (privacy, zero setup); otherwise guide the user. */
25
+ export declare function chooseExtractor(purpose?: "extract" | "profile"): Promise<ChosenExtractor>;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Structured extraction shared by importers and profile synthesis.
3
+ * Uses output_config structured outputs (works on every current model,
4
+ * including Fable 5 where forced tool_choice is not supported).
5
+ */
6
+ import Anthropic from "@anthropic-ai/sdk";
7
+ import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
8
+ import { z } from "zod";
9
+ export const DEFAULT_EXTRACT_MODEL = process.env.PERSNALLY_MODEL ?? "claude-haiku-4-5";
10
+ export const DEFAULT_PROFILE_MODEL = process.env.PERSNALLY_PROFILE_MODEL ?? "claude-fable-5";
11
+ /** Default extractor backed by the Anthropic API; injectable for tests. */
12
+ export const anthropicExtract = async ({ model, instruction, schema, content, maxTokens }) => {
13
+ const client = new Anthropic();
14
+ const response = await client.messages.parse({
15
+ model,
16
+ max_tokens: maxTokens ?? 4000,
17
+ system: instruction,
18
+ output_config: { format: zodOutputFormat(schema) },
19
+ messages: [{ role: "user", content }],
20
+ });
21
+ if (response.stop_reason === "refusal") {
22
+ throw new Error("Extraction request was refused by the model's safety classifiers");
23
+ }
24
+ if (response.parsed_output == null) {
25
+ throw new Error("Extraction returned no parseable output");
26
+ }
27
+ return response.parsed_output;
28
+ };
29
+ // ── Local extraction via Ollama: zero key, zero cloud ───────
30
+ const OLLAMA_URL = process.env.PERSNALLY_OLLAMA_URL ?? "http://127.0.0.1:11434";
31
+ const LOCAL_MODEL_PREFERENCE = ["qwen2.5:14b", "qwen2.5:7b", "llama3.1:8b", "llama3.2", "qwen2.5:1.5b"];
32
+ export const ollamaExtract = async ({ model, instruction, schema, content }) => {
33
+ const r = await fetch(`${OLLAMA_URL}/api/chat`, {
34
+ method: "POST",
35
+ body: JSON.stringify({
36
+ model,
37
+ stream: false,
38
+ format: z.toJSONSchema(schema),
39
+ messages: [
40
+ { role: "system", content: instruction },
41
+ { role: "user", content },
42
+ ],
43
+ }),
44
+ });
45
+ if (!r.ok)
46
+ throw new Error(`ollama: ${r.status} ${await r.text()}`);
47
+ const body = (await r.json());
48
+ return schema.parse(JSON.parse(body.message?.content ?? "{}"));
49
+ };
50
+ async function localModel() {
51
+ if (process.env.PERSNALLY_LOCAL_MODEL)
52
+ return process.env.PERSNALLY_LOCAL_MODEL;
53
+ try {
54
+ const r = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(1500) });
55
+ const tags = (await r.json()).models?.map((m) => m.name) ?? [];
56
+ return LOCAL_MODEL_PREFERENCE.find((p) => tags.some((t) => t === p || t === `${p}:latest`)) ?? tags[0] ?? null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
62
+ /** Anthropic key wins (quality); otherwise local Ollama (privacy, zero setup); otherwise guide the user. */
63
+ export async function chooseExtractor(purpose = "extract") {
64
+ if (process.env.ANTHROPIC_API_KEY) {
65
+ const model = purpose === "profile" ? DEFAULT_PROFILE_MODEL : DEFAULT_EXTRACT_MODEL;
66
+ return { extract: anthropicExtract, model, label: `${model} (Anthropic API)` };
67
+ }
68
+ const model = await localModel();
69
+ if (model) {
70
+ return { extract: ollamaExtract, model, label: `${model} (local via Ollama — nothing leaves this machine)` };
71
+ }
72
+ throw new Error("No extraction engine available. Either:\n" +
73
+ " - persnallyd config set-key <sk-ant-…> (Anthropic API, best quality), or\n" +
74
+ " - install Ollama and `ollama pull llama3.2` (fully local, no key), or\n" +
75
+ " - start key-free with: persnallyd import git <path>");
76
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Thin HTTP client for persnallyd — the daemon is the single source of truth;
3
+ * this MCP server is just a protocol adapter in front of it.
4
+ */
5
+ export declare const DAEMON_HINT = "persnallyd is not running. Start it with `persnallyd serve` (or install: npm i -g persnallyd), then retry.";
6
+ export declare class DaemonUnreachable extends Error {
7
+ constructor();
8
+ }
9
+ export declare function daemonGet<T>(path: string): Promise<T | null>;
10
+ export declare function daemonPost<T>(path: string, body: unknown): Promise<T>;
11
+ export declare function daemonDelete<T>(path: string): Promise<T>;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Thin HTTP client for persnallyd — the daemon is the single source of truth;
3
+ * this MCP server is just a protocol adapter in front of it.
4
+ */
5
+ const BASE = process.env.PERSNALLYD_URL ?? "http://127.0.0.1:4983";
6
+ export const DAEMON_HINT = "persnallyd is not running. Start it with `persnallyd serve` (or install: npm i -g persnallyd), then retry.";
7
+ export class DaemonUnreachable extends Error {
8
+ constructor() { super(DAEMON_HINT); }
9
+ }
10
+ async function request(path, init) {
11
+ try {
12
+ return await fetch(BASE + path, init);
13
+ }
14
+ catch {
15
+ throw new DaemonUnreachable();
16
+ }
17
+ }
18
+ export async function daemonGet(path) {
19
+ const r = await request(path);
20
+ // 404 = not present yet; 403 = scoped out for this client. Both mean "no data", not an error.
21
+ if (r.status === 404 || r.status === 403)
22
+ return null;
23
+ if (!r.ok)
24
+ throw new Error(`daemon ${path}: ${r.status} ${await r.text()}`);
25
+ return r.json();
26
+ }
27
+ export async function daemonPost(path, body) {
28
+ const r = await request(path, {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify(body),
32
+ });
33
+ if (!r.ok)
34
+ throw new Error(`daemon ${path}: ${r.status} ${await r.text()}`);
35
+ return r.json();
36
+ }
37
+ export async function daemonDelete(path) {
38
+ const r = await request(path, { method: "DELETE" });
39
+ if (!r.ok)
40
+ throw new Error(`daemon ${path}: ${r.status} ${await r.text()}`);
41
+ return r.json();
42
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Persnally MCP server — the protocol adapter between AI clients and persnallyd.
4
+ *
5
+ * The daemon owns all state (invariant: one write path, one source of truth);
6
+ * this server translates MCP tool calls into daemon HTTP calls. Claude IS the
7
+ * NLP engine: it fills persnally_track's structured schema from conversation
8
+ * context, so signal extraction costs zero extra inference.
9
+ */
10
+ export {};
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Persnally MCP server — the protocol adapter between AI clients and persnallyd.
4
+ *
5
+ * The daemon owns all state (invariant: one write path, one source of truth);
6
+ * this server translates MCP tool calls into daemon HTTP calls. Claude IS the
7
+ * NLP engine: it fills persnally_track's structured schema from conversation
8
+ * context, so signal extraction costs zero extra inference.
9
+ */
10
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
+ import { z } from "zod";
13
+ import { DAEMON_HINT, DaemonUnreachable, daemonDelete, daemonGet, daemonPost } from "./daemon-client.js";
14
+ import { migrateV1Graph } from "./migrate-v1.js";
15
+ import { getClient, logEvent, setClient } from "./telemetry.js";
16
+ const server = new McpServer({ name: "persnally", version: "2.0.0" });
17
+ function text(s) {
18
+ return { content: [{ type: "text", text: s }] };
19
+ }
20
+ async function guarded(fn) {
21
+ try {
22
+ return await fn();
23
+ }
24
+ catch (e) {
25
+ return text(e instanceof DaemonUnreachable ? DAEMON_HINT : `Persnally error: ${e instanceof Error ? e.message : e}`);
26
+ }
27
+ }
28
+ /** Event sources must match ^mcp:[a-z0-9._-]+$ — slugify whatever name the client reports. */
29
+ function clientSlug() {
30
+ return getClient().toLowerCase().replace(/[^a-z0-9._-]/g, "-");
31
+ }
32
+ /** The north-star metric (context reads/user/week) is measured from these events.
33
+ Recording must never break the read itself — failures only log to stderr. */
34
+ async function recordRead(scope, purpose, items) {
35
+ const client = clientSlug();
36
+ try {
37
+ await daemonPost("/events", [{
38
+ type: "context.read",
39
+ source: `mcp:${client}`,
40
+ payload: { scope, client_purpose: purpose ?? "", items },
41
+ provenance: { kind: "mcp", client },
42
+ }]);
43
+ }
44
+ catch (e) {
45
+ console.error("persnally: context.read not recorded:", e instanceof Error ? e.message : e);
46
+ }
47
+ }
48
+ // ── persnally_track — write path ────────────────────────────
49
+ server.tool("persnally_track", `Track topics and interests from the current conversation to build the user's personal context.
50
+
51
+ Call this when the user discusses topics they care about — and when they make a decision, accept or reject an option, or express a clear preference, capture that as its own signal rather than folding it into a broader topic.
52
+
53
+ GUIDELINES:
54
+ - Extract 1-5 signals per conversation, focused on what the user is ACTIVELY engaged with
55
+ - Weight = how central to the conversation (0.1 brief, 1.0 main focus)
56
+ - Depth: "mention" | "moderate" | "deep" (extensive discussion or problem-solving)
57
+ - Sentiment: "negative" means frustration or dislike (deprioritizes, never boosts)
58
+ - Entities are specific names: "Next.js" not "web framework"
59
+
60
+ The user opted in. Only structured signals are stored, locally, never raw messages.`, {
61
+ topics: z.array(z.object({
62
+ topic: z.string().describe("The topic, decision, or preference (e.g. 'Rust async programming', 'chose SQLite over Postgres')"),
63
+ weight: z.number().min(0).max(1),
64
+ intent: z.enum(["learning", "building", "researching", "deciding", "discussing", "debugging"]),
65
+ sentiment: z.enum(["positive", "negative", "neutral"]),
66
+ depth: z.enum(["mention", "moderate", "deep"]),
67
+ category: z.enum(["technology", "business", "finance", "career", "health", "science", "creative", "education", "lifestyle", "news", "other"]),
68
+ entities: z.array(z.string()),
69
+ })).min(1),
70
+ }, async ({ topics }) => guarded(async () => {
71
+ logEvent("tool_call", { tool: "persnally_track", topics: topics.length });
72
+ const client = clientSlug();
73
+ const events = topics.map((t) => ({
74
+ type: "signal.topic",
75
+ source: `mcp:${client}`,
76
+ payload: t,
77
+ provenance: { kind: "mcp", client },
78
+ }));
79
+ await daemonPost("/events", events);
80
+ return text(`Recorded ${topics.length} signal(s): ${topics.map((t) => t.topic).join(", ")}.`);
81
+ }));
82
+ // ── persnally_context — read path (the Phase 2 core) ────────
83
+ server.tool("persnally_context", `Get the user's personal context: who they are, what they're working on, and their current interests.
84
+
85
+ Call this at the START of a conversation (or when personalization would improve your answer) so your responses fit this specific user instead of a generic one.`, {
86
+ detail: z.enum(["brief", "full"]).optional().default("brief"),
87
+ purpose: z.string().max(200).optional().describe("Why context is being read right now, in a short phrase (e.g. 'tailor architecture advice')"),
88
+ }, async ({ detail, purpose }) => guarded(async () => {
89
+ logEvent("tool_call", { tool: "persnally_context", detail });
90
+ const client = encodeURIComponent(getClient());
91
+ const [profile, topics] = await Promise.all([
92
+ daemonGet(`/profile?client=${client}`),
93
+ daemonGet(`/topics?limit=${detail === "full" ? 25 : 10}&client=${client}`),
94
+ ]);
95
+ if (!profile && !topics?.length) {
96
+ return text("No context yet — the user hasn't imported data or tracked any signals.");
97
+ }
98
+ let out = "";
99
+ let items = topics?.length ?? 0;
100
+ if (profile) {
101
+ out += `# About this user\n${profile.headline}\n\n`;
102
+ const sections = detail === "full" ? profile.sections : profile.sections.slice(0, 3);
103
+ items += sections.length;
104
+ out += sections.map((s) => `## ${s.title}\n${s.body}`).join("\n\n");
105
+ }
106
+ if (topics?.length) {
107
+ out += `\n\n# Current interests (decay-weighted)\n`;
108
+ out += topics.map((t) => `- ${t.topic} (${t.category}, ${t.dominant_intent}, weight ${t.weight.toFixed(2)})`).join("\n");
109
+ }
110
+ await recordRead(detail, purpose, items);
111
+ return text(out);
112
+ }));
113
+ // ── persnally_interests — transparency view ─────────────────
114
+ server.tool("persnally_interests", `Show the user their own tracked interest profile — what Persnally has learned. Use when the user asks what Persnally knows about them.`, {}, async () => guarded(async () => {
115
+ logEvent("tool_call", { tool: "persnally_interests" });
116
+ const [stats, topics] = await Promise.all([
117
+ daemonGet("/stats"),
118
+ daemonGet("/topics?limit=20"),
119
+ ]);
120
+ if (!topics?.length)
121
+ return text("Nothing tracked yet. Chat naturally, or import your AI history with `persnallyd import`.");
122
+ let out = `## Your interest profile\n${stats?.total ?? 0} events, ${topics.length} top topics. Dashboard: http://127.0.0.1:4983\n\n`;
123
+ for (const t of topics) {
124
+ const sentiment = t.sentiment_balance > 0.2 ? "+" : t.sentiment_balance < -0.2 ? "−" : "·";
125
+ out += `- ${t.topic} — ${t.weight.toFixed(2)} (${t.category}, ${t.dominant_intent}, ${sentiment}, ${t.signals}×)\n`;
126
+ }
127
+ return text(out);
128
+ }));
129
+ // ── persnally_forget — privacy control ──────────────────────
130
+ server.tool("persnally_forget", `Hard-delete a topic (and everything derived from it) from the user's context, or wipe all data. Privacy control — always honor it.`, {
131
+ topic: z.string().optional().describe("Topic to remove. Omit with clear_all=true to wipe everything."),
132
+ clear_all: z.boolean().optional().default(false),
133
+ }, async ({ topic, clear_all }) => guarded(async () => {
134
+ logEvent("tool_call", { tool: "persnally_forget", clear_all });
135
+ if (clear_all) {
136
+ await daemonDelete("/events?confirm=all");
137
+ return text("All Persnally data deleted. The store is empty.");
138
+ }
139
+ if (!topic)
140
+ return text("Name a topic to forget, or set clear_all.");
141
+ const r = await daemonDelete(`/topics/${encodeURIComponent(topic)}`);
142
+ return text(r.deleted ? `Deleted ${r.deleted} event(s) for "${topic}", including derived data.` : `"${topic}" not found.`);
143
+ }));
144
+ // ── start ───────────────────────────────────────────────────
145
+ async function main() {
146
+ const transport = new StdioServerTransport();
147
+ server.server.oninitialized = () => {
148
+ setClient(server.server.getClientVersion()?.name);
149
+ logEvent("session_start");
150
+ migrateV1Graph()
151
+ .then((n) => { if (n > 0)
152
+ logEvent("v1_migration", { nodes: n }); })
153
+ .catch(() => { });
154
+ };
155
+ await server.connect(transport);
156
+ console.error("Persnally MCP server v2 running (daemon-backed)");
157
+ }
158
+ main().catch(console.error);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * One-time migration of the v1 aggregated interest graph into the event store.
3
+ * v1 nodes are lossy aggregates, so each becomes a single representative
4
+ * signal.topic event; the original file is renamed, never deleted.
5
+ */
6
+ export declare function migrateV1Graph(): Promise<number>;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * One-time migration of the v1 aggregated interest graph into the event store.
3
+ * v1 nodes are lossy aggregates, so each becomes a single representative
4
+ * signal.topic event; the original file is renamed, never deleted.
5
+ */
6
+ import { existsSync, readFileSync, renameSync } from "fs";
7
+ import { homedir } from "os";
8
+ import { join } from "path";
9
+ import { daemonPost } from "./daemon-client.js";
10
+ const GRAPH_FILE = join(homedir(), ".persnally", "interest-graph.json");
11
+ const INTENTS = new Set(["learning", "building", "researching", "deciding", "discussing", "debugging"]);
12
+ const CATEGORIES = new Set([
13
+ "technology", "business", "finance", "career", "health",
14
+ "science", "creative", "education", "lifestyle", "news", "other",
15
+ ]);
16
+ export async function migrateV1Graph() {
17
+ if (!existsSync(GRAPH_FILE))
18
+ return 0;
19
+ let nodes;
20
+ try {
21
+ nodes = JSON.parse(readFileSync(GRAPH_FILE, "utf-8")).nodes ?? {};
22
+ }
23
+ catch {
24
+ return 0;
25
+ }
26
+ const entries = Object.values(nodes);
27
+ if (entries.length > 0) {
28
+ const batch = `v1-migration-${Date.now()}`;
29
+ const events = entries.map((n) => ({
30
+ type: "signal.topic",
31
+ source: "system",
32
+ ts: new Date(n.last_seen).toISOString(),
33
+ payload: {
34
+ topic: n.topic,
35
+ weight: Math.min(Math.max(n.current_weight, 0.05), 1),
36
+ intent: INTENTS.has(n.dominant_intent) ? n.dominant_intent : "discussing",
37
+ sentiment: n.sentiment_balance > 0.2 ? "positive" : n.sentiment_balance < -0.2 ? "negative" : "neutral",
38
+ depth: n.avg_depth >= 0.8 ? "deep" : n.avg_depth >= 0.45 ? "moderate" : "mention",
39
+ category: CATEGORIES.has(n.category) ? n.category : "other",
40
+ entities: (n.entities ?? []).slice(0, 20),
41
+ },
42
+ provenance: { kind: "import", batch, file: "interest-graph.json" },
43
+ }));
44
+ await daemonPost("/events", events);
45
+ }
46
+ renameSync(GRAPH_FILE, GRAPH_FILE + ".v1-migrated");
47
+ return entries.length;
48
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Local-only telemetry for the Phase 0 capture-rate experiment.
3
+ * Appends one JSON line per event to ~/.persnally/telemetry.jsonl — counts and
4
+ * timestamps only, never conversation content. Analyzed by experiments/capture_rate.py.
5
+ */
6
+ export declare function setClient(name: string | undefined): void;
7
+ export declare function getClient(): string;
8
+ export declare function logEvent(event: string, data?: Record<string, unknown>): void;