persnally 2.4.0 → 2.5.1

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
@@ -93,14 +93,16 @@ persnallyd import claude|claude-code|chatgpt|git <path>
93
93
  persnallyd scope <client> <categories> # limit what a client can read
94
94
  persnallyd profile # synthesize the profile
95
95
  persnallyd consolidate # reflect now: refresh decay, add behavior patterns
96
+ persnallyd voice # refresh your "how you write" fingerprint (offline)
96
97
  persnallyd show [topics|events|profile]
97
- persnallyd forget <topic> | --all | --batch <id>
98
+ persnallyd activity # context-read engagement over time (retention pulse)
99
+ persnallyd forget <topic> | --all | --batch <id> | --style <dim> <pattern>
98
100
  persnallyd config set-key <sk-ant-…> # key for the background daemon
99
101
  ```
100
102
 
101
103
  ## Status
102
104
 
103
- Early and moving fast — see [ROADMAP.md](./ROADMAP.md). Today: import from Claude, ChatGPT, Claude Code, and git; a decay-weighted interest graph; an evidence-linked profile; a local dashboard; per-client permission scoping; nightly consolidation; and the MCP layer that serves it all. Next: cross-tool context everywhere, then a behavior model that can answer *what would I do here?*
105
+ Early and moving fast — see [ROADMAP.md](./ROADMAP.md). Today: import from Claude, ChatGPT, Claude Code, and git; a decay-weighted interest graph; an evidence-linked profile; a voice & convention layer so connected tools answer the way you write; a local dashboard with full provenance and one-click deletion; per-client permission scoping; nightly consolidation; and the MCP layer that serves it all. Next: cross-tool context everywhere, then a behavior model that can answer *what would I do here?*
104
106
 
105
107
  ## License
106
108
 
package/build/src/cli.js CHANGED
@@ -47,6 +47,7 @@ Usage:
47
47
  persnallyd forget --all Delete all data
48
48
  persnallyd forget --batch <id> Undo one import batch
49
49
  persnallyd status Store stats and daemon health
50
+ persnallyd activity Context-read engagement over time (retention pulse)
50
51
  persnallyd start [--port N] Start the daemon in the background
51
52
  persnallyd stop Stop the background daemon
52
53
  persnallyd serve [--port N] Run the daemon in the foreground (127.0.0.1:${DEFAULT_PORT})
@@ -462,6 +463,22 @@ async function main() {
462
463
  console.log(`Autostart: ${autostartInstalled() ? "installed" : "not installed"}`);
463
464
  return;
464
465
  }
466
+ case "activity": {
467
+ const store = new EventStore();
468
+ const a = store.activity();
469
+ store.close();
470
+ if (!a.firstEventAt) {
471
+ console.log("No activity yet — run an import or connect a client.");
472
+ return;
473
+ }
474
+ const verdict = a.retainedWeek2 === null ? `in progress (day ${a.daysSinceFirstRead}/14 of reads)` : a.retainedWeek2 ? "active ✓" : "inactive ✗";
475
+ console.log(`Onboarded ${a.daysSinceFirst}d ago · ${a.totalReads} context read(s) total`);
476
+ console.log(`Reads: ${a.reads7d} this week · ${a.reads30d} this month`);
477
+ console.log(`Active: ${a.activeDays7d}/7 days · ${a.activeDays14d}/14 days`);
478
+ console.log(`Week-2 retention: ${verdict}`);
479
+ console.log(`Last 14 days: ${sparkline(a.daily.map((d) => d.reads))}`);
480
+ return;
481
+ }
465
482
  case "start": {
466
483
  const existing = runningPid();
467
484
  if (existing)
@@ -527,6 +544,11 @@ async function main() {
527
544
  process.exitCode = cmd ? 1 : 0;
528
545
  }
529
546
  }
547
+ function sparkline(values) {
548
+ const blocks = "▁▂▃▄▅▆▇█";
549
+ const max = Math.max(...values, 1);
550
+ return values.map((v) => (v === 0 ? "·" : blocks[Math.min(blocks.length - 1, Math.floor((v / max) * (blocks.length - 1)))])).join("");
551
+ }
530
552
  function summarize(payload) {
531
553
  const s = JSON.stringify(payload);
532
554
  return s.length > 80 ? s.slice(0, 77) + "..." : s;
@@ -41,6 +41,9 @@ export function startDaemon(store, port = DEFAULT_PORT) {
41
41
  if (req.method === "GET" && url.pathname === "/stats") {
42
42
  return json(res, 200, store.stats());
43
43
  }
44
+ if (req.method === "GET" && url.pathname === "/activity") {
45
+ return json(res, 200, store.activity());
46
+ }
44
47
  if (req.method === "GET" && url.pathname === "/topics") {
45
48
  const client = url.searchParams.get("client");
46
49
  const allowed = client ? allowedCategories(client) : null;
@@ -153,6 +153,11 @@
153
153
  .read .what .l1 b { color: var(--text); } .read .what .l1 .p { color: var(--dim); }
154
154
  .read .what .l2 { font-size: 11px; color: var(--faint); margin-top: 2px; }
155
155
  .read .when { font-size: 11.5px; color: var(--dim); white-space: nowrap; }
156
+ .engage { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; font-size: 12.5px; color: var(--dim); margin: 0 0 16px; }
157
+ .engage b { color: var(--text); font-weight: 600; }
158
+ .engage .spark { display: inline-flex; align-items: flex-end; gap: 2px; height: 22px; }
159
+ .engage .spark i { width: 6px; min-height: 2px; border-radius: 1px; display: inline-block; }
160
+ .engage .rk { color: var(--faint); }
156
161
 
157
162
  /* ── reflection ── */
158
163
  .refl { border-left: 2px solid var(--line-2); padding: 4px 0 4px 16px; margin-bottom: 16px; }
@@ -238,6 +243,7 @@
238
243
  <div class="value-num num" id="valueNum">0</div>
239
244
  <div class="value-cap" id="valueCap"></div>
240
245
  </div>
246
+ <div class="engage" id="engage"></div>
241
247
  <div class="card" id="reads" style="padding:6px 16px"></div>
242
248
  </section>
243
249
 
@@ -417,6 +423,22 @@ function renderReads(reads, total) {
417
423
  }).join("");
418
424
  }
419
425
 
426
+ /* ── engagement (retention pulse) ── */
427
+ function renderEngage(a) {
428
+ const el = $("engage");
429
+ if (!a || !a.firstEventAt || !a.daily) { el.innerHTML = ""; return; }
430
+ const max = Math.max(...a.daily.map((d) => d.reads), 1);
431
+ const bars = a.daily.map((d, i) => {
432
+ const today = i === a.daily.length - 1;
433
+ const h = d.reads ? Math.max(3, Math.round((d.reads / max) * 22)) : 2;
434
+ const c = d.reads ? (today ? "var(--text)" : "var(--dim)") : "var(--line-2)";
435
+ return `<i style="height:${h}px;background:${c}" title="${esc(d.date)}: ${d.reads}"></i>`;
436
+ }).join("");
437
+ const ret = a.retainedWeek2 === null ? `day ${a.daysSinceFirstRead}/14` : (a.retainedWeek2 ? "active ✓" : "inactive ✗");
438
+ el.innerHTML = `<span><b>${a.reads7d}</b> this week</span><span>active <b>${a.activeDays14d}</b> of last 14 days</span>` +
439
+ `<span class="spark" title="context reads, last 14 days">${bars}</span><span class="rk">week-2: ${esc(ret)}</span>`;
440
+ }
441
+
420
442
  /* ── reflections ── */
421
443
  function renderReflections(assertions) {
422
444
  const behav = assertions.filter(a => a.payload && (a.payload.kind==="behavior" || a.payload.kind==="preference"));
@@ -594,16 +616,16 @@ $("vList").onclick = () => switchView("list");
594
616
 
595
617
  /* ── load ── */
596
618
  async function loadAll() {
597
- let stats, profile, topics, reads, assertions, voice;
619
+ let stats, profile, topics, reads, assertions, voice, activity;
598
620
  try {
599
- [stats, profile, topics, reads, assertions, voice] = await Promise.all([
621
+ [stats, profile, topics, reads, assertions, voice, activity] = await Promise.all([
600
622
  get("/stats"), get("/profile"), get("/topics?limit=40"),
601
- get("/events?type=context.read&limit=60"), get("/events?type=signal.assertion&limit=20"), get("/voice"),
623
+ get("/events?type=context.read&limit=60"), get("/events?type=signal.assertion&limit=20"), get("/voice"), get("/activity"),
602
624
  ]);
603
625
  if (!stats) throw new Error("no daemon");
604
626
  } catch (e) {
605
627
  DEMO = true; $("ribbon").classList.add("show");
606
- ({ stats, profile, topics, reads, assertions, voice } = DEMO_DATA());
628
+ ({ stats, profile, topics, reads, assertions, voice, activity } = DEMO_DATA());
607
629
  }
608
630
  topics = topics||[]; reads = reads||[]; assertions = assertions||[];
609
631
  const st = $("status");
@@ -618,6 +640,7 @@ async function loadAll() {
618
640
  renderTopicList(topics);
619
641
  renderMap(topics);
620
642
  renderReads(reads, total);
643
+ renderEngage(activity);
621
644
  renderReflections(assertions);
622
645
  saveSnapshot(topics);
623
646
  }
@@ -635,9 +658,10 @@ $("reflect").onclick = async () => {
635
658
  };
636
659
  setInterval(async () => {
637
660
  if (DEMO) return;
638
- const [reads, stats, voice] = await Promise.all([get("/events?type=context.read&limit=60"), get("/stats"), get("/voice")]);
661
+ const [reads, stats, voice, activity] = await Promise.all([get("/events?type=context.read&limit=60"), get("/stats"), get("/voice"), get("/activity")]);
639
662
  if (reads && stats) renderReads(reads, (stats.byType && stats.byType["context.read"]) || reads.length);
640
663
  if (voice) renderVoice(voice); // live: voice signals captured mid-session show up without a reload
664
+ if (activity) renderEngage(activity);
641
665
  }, 25000);
642
666
 
643
667
  loadAll();
@@ -683,6 +707,7 @@ function DEMO_DATA() {
683
707
  { ts:iso(86400000*2), payload:{ claim:"You consistently choose local-first, auditable tools over cloud convenience.", kind:"preference", confidence:0.86 }, provenance:{ kind:"derived" } },
684
708
  { ts:iso(86400000*6), payload:{ claim:"You generate new ideas fastest right when your main bet stalls.", kind:"behavior", confidence:0.71 }, provenance:{ kind:"derived" } },
685
709
  ],
710
+ activity: { firstEventAt: iso(86400000*214), firstReadAt: iso(86400000*210), lastReadAt: iso(3600000*2), daysSinceFirst: 214, daysSinceFirstRead: 210, totalReads: 1247, reads7d: 33, reads30d: 142, activeDays7d: 5, activeDays14d: 12, retainedWeek2: true, daily: [2,0,3,5,1,4,6,2,0,3,7,5,8,4].map((r,i)=>({ date:new Date(now-(13-i)*86400000).toISOString().slice(0,10), reads:r })) },
686
711
  voice: {
687
712
  pack: "Write like this user: terse — short, declarative sentences; leads with imperatives, minimal preamble; states things flatly; no emoji; casual register (lowercases “i”); recurring phrasing: “be 100% sure”, “industry best practices”, “validate whether it's valid or not”.",
688
713
  items: [
@@ -35,6 +35,23 @@ export interface StoredProfile {
35
35
  generated_at: string;
36
36
  model: string;
37
37
  }
38
+ export interface Activity {
39
+ firstEventAt: string | null;
40
+ firstReadAt: string | null;
41
+ lastReadAt: string | null;
42
+ daysSinceFirst: number;
43
+ daysSinceFirstRead: number;
44
+ totalReads: number;
45
+ reads7d: number;
46
+ reads30d: number;
47
+ activeDays7d: number;
48
+ activeDays14d: number;
49
+ retainedWeek2: boolean | null;
50
+ daily: {
51
+ date: string;
52
+ reads: number;
53
+ }[];
54
+ }
38
55
  export declare class EventStore {
39
56
  private db;
40
57
  constructor(path?: string);
@@ -53,6 +70,12 @@ export declare class EventStore {
53
70
  first: string | null;
54
71
  last: string | null;
55
72
  };
73
+ /**
74
+ * Engagement over time from context.read events — the retention pulse.
75
+ * Local/per-install only (this machine); aggregate cohort retention would
76
+ * require opt-in telemetry. `now` is injectable for deterministic tests.
77
+ */
78
+ activity(now?: number): Activity;
56
79
  topics(limit?: number): TopicRow[];
57
80
  /** Re-derive view_topics from signal.topic events using decayed per-signal weighting. */
58
81
  rebuild(now?: number): void;
@@ -150,6 +150,60 @@ export class EventStore {
150
150
  const span = this.db.prepare("SELECT MIN(ts) first, MAX(ts) last FROM events").get();
151
151
  return { total, byType: group("type"), bySource: group("source"), ...span };
152
152
  }
153
+ /**
154
+ * Engagement over time from context.read events — the retention pulse.
155
+ * Local/per-install only (this machine); aggregate cohort retention would
156
+ * require opt-in telemetry. `now` is injectable for deterministic tests.
157
+ */
158
+ activity(now = Date.now()) {
159
+ const DAY = 86_400_000;
160
+ const dayKey = (ms) => new Date(ms).toISOString().slice(0, 10);
161
+ const reads = this.query({ type: "context.read", limit: 1_000_000 }); // ts DESC
162
+ const firstEventAt = this.db.prepare("SELECT MIN(ts) m FROM events").get().m;
163
+ const firstMs = firstEventAt ? new Date(firstEventAt).getTime() : null;
164
+ // Retention is anchored to when serving actually began (the first read), not
165
+ // onboarding — so a gap between setup and the first read can't read as a
166
+ // false "not retained". For a fresh install the two are minutes apart.
167
+ const firstReadAt = reads.length ? reads[reads.length - 1].ts : null;
168
+ const firstReadMs = firstReadAt ? new Date(firstReadAt).getTime() : null;
169
+ const daily = new Map();
170
+ for (let i = 13; i >= 0; i--)
171
+ daily.set(dayKey(now - i * DAY), 0);
172
+ let reads7d = 0, reads30d = 0, week2Read = false;
173
+ const days7 = new Set(), days14 = new Set();
174
+ for (const e of reads) {
175
+ const t = new Date(e.ts).getTime();
176
+ if (!Number.isFinite(t))
177
+ continue;
178
+ const age = now - t, k = dayKey(t);
179
+ if (age <= 7 * DAY) {
180
+ reads7d++;
181
+ days7.add(k);
182
+ }
183
+ if (age <= 14 * DAY)
184
+ days14.add(k);
185
+ if (age <= 30 * DAY)
186
+ reads30d++;
187
+ if (daily.has(k))
188
+ daily.set(k, (daily.get(k) ?? 0) + 1);
189
+ if (firstReadMs !== null && t >= firstReadMs + 7 * DAY && t < firstReadMs + 14 * DAY)
190
+ week2Read = true;
191
+ }
192
+ return {
193
+ firstEventAt,
194
+ firstReadAt,
195
+ lastReadAt: reads.length ? reads[0].ts : null,
196
+ daysSinceFirst: firstMs !== null ? Math.max(0, Math.floor((now - firstMs) / DAY)) : 0,
197
+ daysSinceFirstRead: firstReadMs !== null ? Math.max(0, Math.floor((now - firstReadMs) / DAY)) : 0,
198
+ totalReads: reads.length,
199
+ reads7d,
200
+ reads30d,
201
+ activeDays7d: days7.size,
202
+ activeDays14d: days14.size,
203
+ retainedWeek2: firstReadMs !== null && now >= firstReadMs + 14 * DAY ? week2Read : null,
204
+ daily: [...daily.entries()].map(([date, r]) => ({ date, reads: r })),
205
+ };
206
+ }
153
207
  topics(limit = 50) {
154
208
  const rows = this.db
155
209
  .prepare("SELECT * FROM view_topics ORDER BY weight DESC LIMIT ?")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persnally",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "license": "FSL-1.1-MIT",
5
5
  "description": "Your own context engine — local-first, across every AI. So every AI finally knows you.",
6
6
  "type": "module",