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,13 @@
1
+ /**
2
+ * Writes the Persnally MCP server into AI clients' configs.
3
+ * Only touches clients that are actually installed; merges, never clobbers.
4
+ */
5
+ export declare const CLIENTS: readonly ["claude-code", "claude-desktop", "cursor"];
6
+ export type Client = (typeof CLIENTS)[number];
7
+ export declare function mcpServerPath(): string;
8
+ /** Returns the config file written, or null when the client isn't installed. */
9
+ export declare function connectClient(client: Client): string | null;
10
+ export declare function connectAll(): {
11
+ client: Client;
12
+ file: string | null;
13
+ }[];
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Writes the Persnally MCP server into AI clients' configs.
3
+ * Only touches clients that are actually installed; merges, never clobbers.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join } from "node:path";
8
+ export const CLIENTS = ["claude-code", "claude-desktop", "cursor"];
9
+ function configPathFor(client) {
10
+ const home = homedir();
11
+ switch (client) {
12
+ case "claude-code": {
13
+ const file = join(home, ".claude.json");
14
+ return { file, installed: existsSync(file) || existsSync(join(home, ".claude")) };
15
+ }
16
+ case "claude-desktop": {
17
+ const dir = join(home, "Library", "Application Support", "Claude");
18
+ return { file: join(dir, "claude_desktop_config.json"), installed: existsSync(dir) };
19
+ }
20
+ case "cursor": {
21
+ const dir = join(home, ".cursor");
22
+ return { file: join(dir, "mcp.json"), installed: existsSync(dir) };
23
+ }
24
+ }
25
+ }
26
+ export function mcpServerPath() {
27
+ if (process.env.PERSNALLY_MCP && existsSync(process.env.PERSNALLY_MCP))
28
+ return process.env.PERSNALLY_MCP;
29
+ // Bundled in this package: build/src/connect.js → build/src/mcp/index.js
30
+ const bundled = join(import.meta.dirname, "mcp", "index.js");
31
+ if (existsSync(bundled))
32
+ return bundled;
33
+ throw new Error("Persnally MCP server build not found — set PERSNALLY_MCP to its index.js");
34
+ }
35
+ /** Returns the config file written, or null when the client isn't installed. */
36
+ export function connectClient(client) {
37
+ const { file, installed } = configPathFor(client);
38
+ if (!installed)
39
+ return null;
40
+ const cfg = existsSync(file)
41
+ ? JSON.parse(readFileSync(file, "utf-8"))
42
+ : {};
43
+ const servers = (cfg.mcpServers ??= {});
44
+ servers.persnally = { command: "node", args: [mcpServerPath()] };
45
+ mkdirSync(dirname(file), { recursive: true });
46
+ writeFileSync(file, JSON.stringify(cfg, null, 2) + "\n");
47
+ return file;
48
+ }
49
+ export function connectAll() {
50
+ return CLIENTS.map((client) => ({ client, file: connectClient(client) }));
51
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Nightly consolidation — the daemon reflects while the user sleeps.
3
+ * Always refreshes decay; with an engine and enough new signal it emits
4
+ * behavior assertions (provenance: derived from the events considered)
5
+ * and re-synthesizes the profile. Last-run state lives in config, not
6
+ * the event log — it's operational state, not user data.
7
+ */
8
+ import type { ChosenExtractor } from "./llm.js";
9
+ import type { EventStore } from "./store.js";
10
+ export declare const CONSOLIDATION_HOUR = 3;
11
+ export interface ConsolidationResult {
12
+ newSignals: number;
13
+ assertions: number;
14
+ profileRefreshed: boolean;
15
+ }
16
+ /** Run once per local day, at or after the consolidation hour. */
17
+ export declare function shouldRunNow(lastRun: string | undefined, now: Date): boolean;
18
+ export declare function runConsolidation(store: EventStore, engine: ChosenExtractor | null, now?: Date): Promise<ConsolidationResult>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Nightly consolidation — the daemon reflects while the user sleeps.
3
+ * Always refreshes decay; with an engine and enough new signal it emits
4
+ * behavior assertions (provenance: derived from the events considered)
5
+ * and re-synthesizes the profile. Last-run state lives in config, not
6
+ * the event log — it's operational state, not user data.
7
+ */
8
+ import { z } from "zod";
9
+ import { loadConfig, saveConfig } from "./config.js";
10
+ import { newEvent, PAYLOAD_SCHEMAS } from "./events.js";
11
+ import { synthesizeProfile } from "./profile.js";
12
+ const ASSERTION_MIN_SIGNALS = 5;
13
+ const PROFILE_MIN_SIGNALS = 10;
14
+ const PROVENANCE_CAP = 100;
15
+ export const CONSOLIDATION_HOUR = 3; // local time
16
+ /** Run once per local day, at or after the consolidation hour. */
17
+ export function shouldRunNow(lastRun, now) {
18
+ if (now.getHours() < CONSOLIDATION_HOUR)
19
+ return false;
20
+ if (!lastRun)
21
+ return true;
22
+ const last = new Date(lastRun);
23
+ return last.toDateString() !== now.toDateString();
24
+ }
25
+ const reflection = z.object({
26
+ assertions: z.array(PAYLOAD_SCHEMAS["signal.assertion"]).max(3),
27
+ });
28
+ export async function runConsolidation(store, engine, now = new Date()) {
29
+ const lastRun = loadConfig().last_consolidation;
30
+ const since = typeof lastRun === "string" ? lastRun : new Date(0).toISOString();
31
+ // recorded_at, not ts: imports carry historical ts but are new to the store.
32
+ const newSignals = store
33
+ .query({ limit: 100_000, recordedSince: since })
34
+ .filter((e) => e.type.startsWith("signal."));
35
+ // Decay shifts daily even with no new events — always re-derive.
36
+ store.rebuild(now.getTime());
37
+ let assertions = [];
38
+ if (engine && newSignals.length >= ASSERTION_MIN_SIGNALS) {
39
+ const summary = newSignals
40
+ .map((e) => {
41
+ const p = e.payload;
42
+ return e.type === "signal.topic"
43
+ ? `- topic: ${p.topic} (${p.intent}, ${p.sentiment}, weight ${p.weight})`
44
+ : `- ${e.type}: ${JSON.stringify(p).slice(0, 140)}`;
45
+ })
46
+ .join("\n");
47
+ const result = await engine.extract({
48
+ model: engine.model,
49
+ instruction: "These are signals from one user's recent AI activity. Identify at most 3 behavioral patterns worth remembering — recurring focus, a shift in attention, a decision pattern. kind must be 'behavior'. Only assert what this evidence supports; fewer good assertions beat filler.",
50
+ schema: reflection,
51
+ content: summary,
52
+ });
53
+ const from = newSignals.slice(0, PROVENANCE_CAP).map((e) => e.id);
54
+ assertions = reflection.parse(result).assertions.map((a) => newEvent("signal.assertion", "system", a, { kind: "derived", from }, now.toISOString()));
55
+ if (assertions.length) {
56
+ store.append(assertions);
57
+ store.rebuild(now.getTime());
58
+ }
59
+ }
60
+ let profileRefreshed = false;
61
+ if (engine && newSignals.length >= PROFILE_MIN_SIGNALS) {
62
+ await synthesizeProfile(store, engine.extract, engine.model);
63
+ profileRefreshed = true;
64
+ }
65
+ saveConfig({ last_consolidation: now.toISOString() });
66
+ return { newSignals: newSignals.length, assertions: assertions.length, profileRefreshed };
67
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Local HTTP API + dashboard — loopback only, the single access path to the event store.
3
+ * Phase 2's MCP context server will be a client of this API.
4
+ */
5
+ import http from "node:http";
6
+ import type { EventStore } from "./store.js";
7
+ export declare const DEFAULT_PORT = 4983;
8
+ export declare const VERSION: string;
9
+ export declare function startDaemon(store: EventStore, port?: number): http.Server;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Local HTTP API + dashboard — loopback only, the single access path to the event store.
3
+ * Phase 2's MCP context server will be a client of this API.
4
+ */
5
+ import http from "node:http";
6
+ import { readFileSync } from "node:fs";
7
+ import { loadConfig } from "./config.js";
8
+ import { runConsolidation, shouldRunNow } from "./consolidate.js";
9
+ import { allowedCategories, loadScopes } from "./permissions.js";
10
+ import { newEvent, validateEvent } from "./events.js";
11
+ import { chooseExtractor } from "./llm.js";
12
+ import { synthesizeProfile } from "./profile.js";
13
+ export const DEFAULT_PORT = 4983;
14
+ // Single source of truth for the user-visible version: package.json.
15
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
16
+ export const VERSION = pkg.version;
17
+ export function startDaemon(store, port = DEFAULT_PORT) {
18
+ const localHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
19
+ const server = http.createServer(async (req, res) => {
20
+ // Loopback binding alone doesn't stop browsers: webpages can fire
21
+ // no-preflight POSTs at 127.0.0.1 (CSRF) or reach it via DNS rebinding.
22
+ if (!localHosts.includes(req.headers.host ?? "")) {
23
+ return json(res, 403, { error: "forbidden: unrecognized Host" });
24
+ }
25
+ const origin = req.headers.origin;
26
+ if (origin && !localHosts.some((h) => origin === `http://${h}`)) {
27
+ return json(res, 403, { error: "forbidden: cross-origin requests are not allowed" });
28
+ }
29
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
30
+ try {
31
+ if (req.method === "GET" && url.pathname === "/") {
32
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
33
+ return res.end(dashboardHtml());
34
+ }
35
+ if (req.method === "GET" && url.pathname === "/health") {
36
+ return json(res, 200, { ok: true, version: VERSION });
37
+ }
38
+ if (req.method === "GET" && url.pathname === "/stats") {
39
+ return json(res, 200, store.stats());
40
+ }
41
+ if (req.method === "GET" && url.pathname === "/topics") {
42
+ const client = url.searchParams.get("client");
43
+ const allowed = client ? allowedCategories(client) : null;
44
+ let topics = store.topics(num(url, "limit", 50));
45
+ if (allowed)
46
+ topics = topics.filter((t) => allowed.includes(t.category));
47
+ return json(res, 200, topics);
48
+ }
49
+ if (req.method === "GET" && url.pathname === "/profile") {
50
+ // The synthesized profile is holistic prose — a scoped client gets only its
51
+ // allowed topics (above), never the cross-category narrative.
52
+ const client = url.searchParams.get("client");
53
+ if (client && allowedCategories(client) !== null) {
54
+ return json(res, 403, { error: "scoped: this client does not have profile access", scoped: true });
55
+ }
56
+ const profile = store.getProfile();
57
+ return profile ? json(res, 200, profile) : json(res, 404, { error: "no profile synthesized yet" });
58
+ }
59
+ if (req.method === "GET" && url.pathname === "/scopes") {
60
+ return json(res, 200, loadScopes());
61
+ }
62
+ if (req.method === "POST" && url.pathname === "/synthesize") {
63
+ const engine = await chooseExtractor("profile");
64
+ return json(res, 200, await synthesizeProfile(store, engine.extract, engine.model));
65
+ }
66
+ if (req.method === "POST" && url.pathname === "/consolidate") {
67
+ const engine = await chooseExtractor("extract").catch(() => null);
68
+ return json(res, 200, await runConsolidation(store, engine));
69
+ }
70
+ if (req.method === "GET" && url.pathname === "/events") {
71
+ const ids = url.searchParams.get("ids");
72
+ if (ids)
73
+ return json(res, 200, store.getEvents(ids.split(",").filter(Boolean)));
74
+ return json(res, 200, store.query({
75
+ type: url.searchParams.get("type") ?? undefined,
76
+ source: url.searchParams.get("source") ?? undefined,
77
+ since: url.searchParams.get("since") ?? undefined,
78
+ limit: num(url, "limit", 100),
79
+ }));
80
+ }
81
+ if (req.method === "POST" && url.pathname === "/events") {
82
+ // JSON-only forces browsers to preflight (which fails above) — no-preflight
83
+ // content types like text/plain can't reach the write path.
84
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
85
+ return json(res, 415, { error: "Content-Type must be application/json" });
86
+ }
87
+ const body = await readBody(req);
88
+ // The daemon owns event identity: items without an id get one assigned here.
89
+ const events = (Array.isArray(body) ? body : [body]).map((raw) => {
90
+ const r = raw;
91
+ return r.id
92
+ ? validateEvent(r)
93
+ : newEvent(r.type, String(r.source ?? ""), r.payload, r.provenance, typeof r.ts === "string" ? r.ts : undefined);
94
+ });
95
+ store.append(events);
96
+ // Views derive only from signal.* events — skip the O(all-events) rebuild
97
+ // for telemetry writes like context.read.
98
+ if (events.some((e) => e.type.startsWith("signal.")))
99
+ store.rebuild();
100
+ return json(res, 201, { inserted: events.length, ids: events.map((e) => e.id) });
101
+ }
102
+ if (req.method === "DELETE" && url.pathname === "/events") {
103
+ if (url.searchParams.get("confirm") !== "all") {
104
+ return json(res, 400, { error: "destructive: requires ?confirm=all" });
105
+ }
106
+ store.forgetAll();
107
+ return json(res, 200, { deleted: "all" });
108
+ }
109
+ if (req.method === "DELETE" && url.pathname.startsWith("/topics/")) {
110
+ const topic = decodeURIComponent(url.pathname.slice("/topics/".length));
111
+ if (!topic)
112
+ return json(res, 400, { error: "topic required" });
113
+ return json(res, 200, { deleted: store.forgetTopic(topic) });
114
+ }
115
+ return json(res, 404, { error: "not found" });
116
+ }
117
+ catch (e) {
118
+ return json(res, 400, { error: e instanceof Error ? e.message : "bad request" });
119
+ }
120
+ });
121
+ server.listen(port, "127.0.0.1");
122
+ // Nightly reflection: check every 30 min, run once per day at/after the consolidation hour.
123
+ const timer = setInterval(async () => {
124
+ const lastRun = loadConfig().last_consolidation;
125
+ if (!shouldRunNow(typeof lastRun === "string" ? lastRun : undefined, new Date()))
126
+ return;
127
+ try {
128
+ const engine = await chooseExtractor("extract").catch(() => null);
129
+ const r = await runConsolidation(store, engine);
130
+ console.error(`consolidation: ${r.newSignals} new signals, ${r.assertions} assertions, profile ${r.profileRefreshed ? "refreshed" : "kept"}`);
131
+ }
132
+ catch (e) {
133
+ console.error("consolidation failed:", e instanceof Error ? e.message : e);
134
+ }
135
+ }, 30 * 60 * 1000);
136
+ timer.unref();
137
+ server.on("close", () => clearInterval(timer));
138
+ return server;
139
+ }
140
+ let cachedHtml;
141
+ function dashboardHtml() {
142
+ cachedHtml ??= readFileSync(new URL("./dashboard.html", import.meta.url), "utf-8");
143
+ return cachedHtml;
144
+ }
145
+ function json(res, status, body) {
146
+ res.writeHead(status, { "Content-Type": "application/json" });
147
+ res.end(JSON.stringify(body));
148
+ }
149
+ function num(url, key, fallback) {
150
+ const v = Number(url.searchParams.get(key));
151
+ return Number.isFinite(v) && v > 0 ? v : fallback;
152
+ }
153
+ function readBody(req) {
154
+ return new Promise((resolve, reject) => {
155
+ let data = "";
156
+ req.on("data", (chunk) => (data += chunk));
157
+ req.on("end", () => {
158
+ try {
159
+ resolve(JSON.parse(data));
160
+ }
161
+ catch {
162
+ reject(new Error("invalid JSON body"));
163
+ }
164
+ });
165
+ req.on("error", reject);
166
+ });
167
+ }
@@ -0,0 +1,181 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Persnally — your context engine</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d0e12; --panel: #14161c; --panel-2: #1a1d25; --line: #262a35;
10
+ --text: #e8eaf0; --dim: #8b92a5; --accent: #6ee7b7; --warn: #f87171;
11
+ --intent: #93c5fd;
12
+ }
13
+ * { box-sizing: border-box; margin: 0; }
14
+ body {
15
+ background: var(--bg); color: var(--text);
16
+ font: 15px/1.6 -apple-system, "Segoe UI", system-ui, sans-serif;
17
+ max-width: 880px; margin: 0 auto; padding: 40px 24px 80px;
18
+ }
19
+ header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 8px; }
20
+ h1 { font-size: 22px; letter-spacing: -0.3px; }
21
+ h1 span { color: var(--accent); }
22
+ .tagline { color: var(--dim); font-size: 14px; }
23
+ .stats { display: flex; gap: 24px; margin: 22px 0 34px; color: var(--dim); font-size: 13px; }
24
+ .stats b { color: var(--text); font-size: 17px; display: block; }
25
+ section { margin-bottom: 40px; }
26
+ h2 { font-size: 13px; text-transform: uppercase; letter-spacing: 1.4px; color: var(--dim); margin-bottom: 14px; }
27
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 18px 20px; margin-bottom: 12px; }
28
+ .headline { font-size: 17px; font-weight: 600; line-height: 1.5; }
29
+ .meta { color: var(--dim); font-size: 12px; margin-top: 8px; }
30
+ .sec-title { font-weight: 600; margin-bottom: 6px; }
31
+ .sec-body { color: #c6cad6; white-space: pre-wrap; }
32
+ .evidence-btn {
33
+ background: none; border: none; color: var(--accent); cursor: pointer;
34
+ font-size: 12px; padding: 0; margin-top: 10px; font-family: inherit;
35
+ }
36
+ .evidence { margin-top: 10px; border-top: 1px dashed var(--line); padding-top: 10px; display: none; }
37
+ .evidence.open { display: block; }
38
+ .ev-item { font-size: 12.5px; color: var(--dim); padding: 4px 0; }
39
+ .ev-item code { color: var(--intent); background: var(--panel-2); padding: 1px 6px; border-radius: 4px; }
40
+ table { width: 100%; border-collapse: collapse; }
41
+ td { padding: 9px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; font-size: 14px; }
42
+ .bar { height: 6px; border-radius: 3px; background: var(--accent); min-width: 2px; }
43
+ .bar-cell { width: 120px; }
44
+ .cat { color: var(--dim); font-size: 12.5px; }
45
+ .neg { color: var(--warn); }
46
+ .del {
47
+ background: none; border: 1px solid var(--line); color: var(--dim); border-radius: 6px;
48
+ cursor: pointer; font-size: 11.5px; padding: 2px 9px;
49
+ }
50
+ .del:hover { color: var(--warn); border-color: var(--warn); }
51
+ .btn {
52
+ background: var(--accent); color: #07241a; border: none; border-radius: 8px;
53
+ padding: 8px 16px; font-weight: 600; cursor: pointer; font-size: 13.5px;
54
+ }
55
+ .btn:disabled { opacity: 0.5; cursor: wait; }
56
+ .empty { color: var(--dim); padding: 24px; text-align: center; }
57
+ footer { color: var(--dim); font-size: 12px; border-top: 1px solid var(--line); padding-top: 16px; }
58
+ </style>
59
+ </head>
60
+ <body>
61
+ <header>
62
+ <div>
63
+ <h1>persnally<span>d</span></h1>
64
+ <div class="tagline">so every AI finally knows you · local-first, nothing leaves this machine</div>
65
+ </div>
66
+ <button class="btn" id="synthesize">Re-synthesize profile</button>
67
+ </header>
68
+
69
+ <div class="stats" id="stats"></div>
70
+
71
+ <section>
72
+ <h2>Your profile — every claim traceable to evidence</h2>
73
+ <div id="profile"><div class="empty">Loading…</div></div>
74
+ </section>
75
+
76
+ <section>
77
+ <h2>Interest graph — decayed, weighted, deletable</h2>
78
+ <div class="card" style="padding:6px 14px"><table id="topics"></table></div>
79
+ </section>
80
+
81
+ <footer>
82
+ Stored at ~/.persnally/persnally.db · deleting a topic hard-deletes its events and everything derived from them ·
83
+ <code>persnallyd forget --all</code> erases everything
84
+ </footer>
85
+
86
+ <script>
87
+ const $ = (id) => document.getElementById(id);
88
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
89
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
90
+
91
+ async function get(path) {
92
+ const r = await fetch(path);
93
+ if (!r.ok) return null;
94
+ return r.json();
95
+ }
96
+
97
+ async function loadStats() {
98
+ const s = await get("/stats");
99
+ if (!s) return;
100
+ const span = s.first ? `${s.first.slice(0, 10)} → ${s.last.slice(0, 10)}` : "—";
101
+ $("stats").innerHTML = `
102
+ <div><b>${s.total}</b>events</div>
103
+ <div><b>${s.byType["signal.topic"] ?? 0}</b>topic signals</div>
104
+ <div><b>${s.byType["signal.assertion"] ?? 0}</b>assertions</div>
105
+ <div><b>${span}</b>covered</div>`;
106
+ }
107
+
108
+ async function loadProfile() {
109
+ const p = await get("/profile");
110
+ if (!p) {
111
+ $("profile").innerHTML = `<div class="card empty">No profile yet — import data, then hit “Re-synthesize profile”.</div>`;
112
+ return;
113
+ }
114
+ const sections = p.sections.map((s, i) => `
115
+ <div class="card">
116
+ <div class="sec-title">${esc(s.title)}</div>
117
+ <div class="sec-body">${esc(s.body)}</div>
118
+ ${s.evidence_event_ids.length ? `
119
+ <button class="evidence-btn" data-i="${i}">why does it think this? · ${s.evidence_event_ids.length} events</button>
120
+ <div class="evidence" id="ev-${i}" data-ids="${esc(s.evidence_event_ids.join(","))}"></div>` : ""}
121
+ </div>`).join("");
122
+ $("profile").innerHTML = `
123
+ <div class="card"><div class="headline">${esc(p.headline)}</div>
124
+ <div class="meta">generated ${esc(p.generated_at)} · ${esc(p.model)} · synthesized from structured events only</div></div>
125
+ ${sections}`;
126
+
127
+ document.querySelectorAll(".evidence-btn").forEach((btn) =>
128
+ btn.addEventListener("click", () => toggleEvidence(btn.dataset.i)));
129
+ }
130
+
131
+ async function toggleEvidence(i) {
132
+ const box = $(`ev-${i}`);
133
+ box.classList.toggle("open");
134
+ if (box.dataset.loaded || !box.classList.contains("open")) return;
135
+ const events = await get(`/events?ids=${box.dataset.ids}`) ?? [];
136
+ box.innerHTML = events.map((e) => {
137
+ const p = e.payload;
138
+ const summary = e.type === "signal.topic"
139
+ ? `${p.topic} (${p.intent}, ${p.sentiment})`
140
+ : p.claim ?? JSON.stringify(p).slice(0, 120);
141
+ const origin = e.provenance.kind === "import"
142
+ ? `${e.provenance.file}${e.provenance.conversation_uuid ? " · convo " + e.provenance.conversation_uuid.slice(0, 8) : ""}`
143
+ : e.provenance.kind;
144
+ return `<div class="ev-item"><code>${esc(e.type)}</code> ${esc(summary)} — <i>${esc(origin)}, ${esc(e.ts.slice(0, 10))}</i></div>`;
145
+ }).join("") || `<div class="ev-item">events not found (deleted?)</div>`;
146
+ box.dataset.loaded = "1";
147
+ }
148
+
149
+ async function loadTopics() {
150
+ const topics = await get("/topics?limit=40") ?? [];
151
+ const max = Math.max(...topics.map((t) => t.weight), 0.01);
152
+ $("topics").innerHTML = topics.map((t) => `
153
+ <tr>
154
+ <td class="bar-cell"><div class="bar" style="width:${Math.max((t.weight / max) * 100, 2)}%"></div></td>
155
+ <td>${esc(t.topic)}${t.sentiment_balance < -0.2 ? ' <span class="neg">▾</span>' : ""}</td>
156
+ <td class="cat">${esc(t.category)} · ${esc(t.dominant_intent)} · ${t.signals}×</td>
157
+ <td class="cat">${esc(t.last_seen.slice(0, 10))}</td>
158
+ <td><button class="del" data-topic="${esc(t.topic)}">forget</button></td>
159
+ </tr>`).join("") || `<tr><td class="empty">No topics yet — run an import.</td></tr>`;
160
+
161
+ document.querySelectorAll(".del").forEach((btn) =>
162
+ btn.addEventListener("click", async () => {
163
+ if (!confirm(`Hard-delete "${btn.dataset.topic}" and everything derived from it?`)) return;
164
+ await fetch(`/topics/${encodeURIComponent(btn.dataset.topic)}`, { method: "DELETE" });
165
+ loadTopics(); loadStats();
166
+ }));
167
+ }
168
+
169
+ $("synthesize").addEventListener("click", async () => {
170
+ const btn = $("synthesize");
171
+ btn.disabled = true; btn.textContent = "Synthesizing… (can take a minute)";
172
+ const r = await fetch("/synthesize", { method: "POST" });
173
+ if (!r.ok) alert((await r.json()).error ?? "synthesis failed");
174
+ btn.disabled = false; btn.textContent = "Re-synthesize profile";
175
+ loadProfile();
176
+ });
177
+
178
+ loadStats(); loadProfile(); loadTopics();
179
+ </script>
180
+ </body>
181
+ </html>
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Interest weighting, ported from v1's interest-engine with the double-count fix:
3
+ * v1 summed raw weights per signal AND multiplied a frequency bonus on top, defeating
4
+ * the half-life for repeated topics. Here each signal decays individually and
5
+ * frequency emerges from the decayed sum alone.
6
+ */
7
+ export interface WeightSignal {
8
+ ts: string;
9
+ weight: number;
10
+ depth: string;
11
+ sentiment: string;
12
+ intent: string;
13
+ }
14
+ export interface TopicWeight {
15
+ weight: number;
16
+ sentiment_balance: number;
17
+ dominant_intent: string;
18
+ }
19
+ export declare function topicWeight(signals: WeightSignal[], now?: number): TopicWeight;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Interest weighting, ported from v1's interest-engine with the double-count fix:
3
+ * v1 summed raw weights per signal AND multiplied a frequency bonus on top, defeating
4
+ * the half-life for repeated topics. Here each signal decays individually and
5
+ * frequency emerges from the decayed sum alone.
6
+ */
7
+ const HALF_LIFE_DAYS = 7;
8
+ const LAMBDA = Math.LN2 / HALF_LIFE_DAYS;
9
+ const MS_PER_DAY = 86_400_000;
10
+ const MAX_WEIGHT = 10;
11
+ const DEPTH_SCORES = { mention: 0.3, moderate: 0.6, deep: 1.0 };
12
+ const SENTIMENT_VALUES = { positive: 0.5, negative: -0.5, neutral: 0 };
13
+ export function topicWeight(signals, now = Date.now()) {
14
+ let sum = 0;
15
+ let sentiment = 0;
16
+ const intents = new Map();
17
+ for (const s of signals) {
18
+ const days = Math.max((now - Date.parse(s.ts)) / MS_PER_DAY, 0);
19
+ sum += s.weight * (DEPTH_SCORES[s.depth] ?? 0.3) * Math.exp(-LAMBDA * days);
20
+ sentiment += SENTIMENT_VALUES[s.sentiment] ?? 0;
21
+ intents.set(s.intent, (intents.get(s.intent) ?? 0) + 1);
22
+ }
23
+ const balance = signals.length ? sentiment / signals.length : 0;
24
+ // Negative sentiment deprioritizes (floor 0.2), never boosts.
25
+ const sentimentMultiplier = Math.max(0.2, 1 + Math.min(balance, 0) * 0.8);
26
+ // Most-frequent intent — v1 documented this but actually took the latest.
27
+ const dominant = [...intents.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "discussing";
28
+ return {
29
+ weight: Math.min(sum * sentimentMultiplier, MAX_WEIGHT),
30
+ sentiment_balance: balance,
31
+ dominant_intent: dominant,
32
+ };
33
+ }