persnally 2.4.0 → 2.5.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.
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.daysSinceFirst}/14)` : 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.daysSinceFirst}/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), lastReadAt: iso(3600000*2), daysSinceFirst: 214, 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,21 @@ export interface StoredProfile {
35
35
  generated_at: string;
36
36
  model: string;
37
37
  }
38
+ export interface Activity {
39
+ firstEventAt: string | null;
40
+ lastReadAt: string | null;
41
+ daysSinceFirst: number;
42
+ totalReads: number;
43
+ reads7d: number;
44
+ reads30d: number;
45
+ activeDays7d: number;
46
+ activeDays14d: number;
47
+ retainedWeek2: boolean | null;
48
+ daily: {
49
+ date: string;
50
+ reads: number;
51
+ }[];
52
+ }
38
53
  export declare class EventStore {
39
54
  private db;
40
55
  constructor(path?: string);
@@ -53,6 +68,12 @@ export declare class EventStore {
53
68
  first: string | null;
54
69
  last: string | null;
55
70
  };
71
+ /**
72
+ * Engagement over time from context.read events — the retention pulse.
73
+ * Local/per-install only (this machine); aggregate cohort retention would
74
+ * require opt-in telemetry. `now` is injectable for deterministic tests.
75
+ */
76
+ activity(now?: number): Activity;
56
77
  topics(limit?: number): TopicRow[];
57
78
  /** Re-derive view_topics from signal.topic events using decayed per-signal weighting. */
58
79
  rebuild(now?: number): void;
@@ -150,6 +150,53 @@ 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
+ const daily = new Map();
165
+ for (let i = 13; i >= 0; i--)
166
+ daily.set(dayKey(now - i * DAY), 0);
167
+ let reads7d = 0, reads30d = 0, week2Read = false;
168
+ const days7 = new Set(), days14 = new Set();
169
+ for (const e of reads) {
170
+ const t = new Date(e.ts).getTime();
171
+ if (!Number.isFinite(t))
172
+ continue;
173
+ const age = now - t, k = dayKey(t);
174
+ if (age <= 7 * DAY) {
175
+ reads7d++;
176
+ days7.add(k);
177
+ }
178
+ if (age <= 14 * DAY)
179
+ days14.add(k);
180
+ if (age <= 30 * DAY)
181
+ reads30d++;
182
+ if (daily.has(k))
183
+ daily.set(k, (daily.get(k) ?? 0) + 1);
184
+ if (firstMs !== null && t >= firstMs + 7 * DAY && t < firstMs + 14 * DAY)
185
+ week2Read = true;
186
+ }
187
+ return {
188
+ firstEventAt,
189
+ lastReadAt: reads.length ? reads[0].ts : null,
190
+ daysSinceFirst: firstMs !== null ? Math.max(0, Math.floor((now - firstMs) / DAY)) : 0,
191
+ totalReads: reads.length,
192
+ reads7d,
193
+ reads30d,
194
+ activeDays7d: days7.size,
195
+ activeDays14d: days14.size,
196
+ retainedWeek2: firstMs !== null && now >= firstMs + 14 * DAY ? week2Read : null,
197
+ daily: [...daily.entries()].map(([date, r]) => ({ date, reads: r })),
198
+ };
199
+ }
153
200
  topics(limit = 50) {
154
201
  const rows = this.db
155
202
  .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.0",
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",