persnally 2.2.0 → 2.3.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
@@ -42,6 +42,7 @@ Usage:
42
42
  persnallyd show [topics|events|profile] Show topics (default), recent events, or the profile
43
43
  persnallyd context [--full] Emit profile + interests for AI injection (records a context read)
44
44
  persnallyd forget <topic> Hard-delete a topic and everything derived from it
45
+ persnallyd forget --style <dimension> <pattern> Forget a "how you write" pattern for good
45
46
  persnallyd forget --all Delete all data
46
47
  persnallyd forget --batch <id> Undo one import batch
47
48
  persnallyd status Store stats and daemon health
@@ -294,7 +295,7 @@ async function main() {
294
295
  const store = new EventStore();
295
296
  const r = await runConsolidation(store, engine);
296
297
  store.close();
297
- console.log(`Consolidation: ${r.newSignals} new signal(s) since last run, ${r.assertions} behavior assertion(s) added, profile ${r.profileRefreshed ? "refreshed" : "unchanged"}.`);
298
+ console.log(`Consolidation: ${r.newSignals} new signal(s) since last run, ${r.assertions} behavior assertion(s) added, profile ${r.profileRefreshed ? "refreshed" : "unchanged"}, ${r.stylePruned} style signal(s) pruned.`);
298
299
  return;
299
300
  }
300
301
  case "profile": {
@@ -396,11 +397,15 @@ async function main() {
396
397
  else if (args[0] === "--batch" && args[1]) {
397
398
  console.log(`Deleted ${store.forgetBatch(args[1])} events from batch ${args[1]}.`);
398
399
  }
400
+ else if (args[0] === "--style" && args[1] && args[2]) {
401
+ store.forgetStyle(args[1], args[2]);
402
+ console.log(`Forgot "${args[2]}" (${args[1]}) — won't be re-learned.`);
403
+ }
399
404
  else if (args[0]) {
400
405
  console.log(`Deleted ${store.forgetTopic(args[0])} events for "${args[0]}".`);
401
406
  }
402
407
  else {
403
- die("usage: persnallyd forget <topic> | --all | --batch <id>");
408
+ die("usage: persnallyd forget <topic> | --all | --batch <id> | --style <dimension> <pattern>");
404
409
  }
405
410
  store.close();
406
411
  return;
@@ -12,6 +12,7 @@ export interface ConsolidationResult {
12
12
  newSignals: number;
13
13
  assertions: number;
14
14
  profileRefreshed: boolean;
15
+ stylePruned: number;
15
16
  }
16
17
  /** Run once per local day, at or after the consolidation hour. */
17
18
  export declare function shouldRunNow(lastRun: string | undefined, now: Date): boolean;
@@ -13,6 +13,7 @@ const ASSERTION_MIN_SIGNALS = 5;
13
13
  const PROFILE_MIN_SIGNALS = 10;
14
14
  const PROVENANCE_CAP = 100;
15
15
  export const CONSOLIDATION_HOUR = 3; // local time
16
+ const STYLE_BACKLOG_CAP = 80;
16
17
  /** Run once per local day, at or after the consolidation hour. */
17
18
  export function shouldRunNow(lastRun, now) {
18
19
  if (now.getHours() < CONSOLIDATION_HOUR)
@@ -34,6 +35,9 @@ export async function runConsolidation(store, engine, now = new Date()) {
34
35
  .filter((e) => e.type.startsWith("signal."));
35
36
  // Decay shifts daily even with no new events — always re-derive.
36
37
  store.rebuild(now.getTime());
38
+ // Distill the voice layer: live `observed` capture has no equivalent of decay,
39
+ // so bound the backlog to the richest signals (capture small, store distilled).
40
+ const stylePruned = store.pruneStyle(STYLE_BACKLOG_CAP);
37
41
  let assertions = [];
38
42
  if (engine && newSignals.length >= ASSERTION_MIN_SIGNALS) {
39
43
  const summary = newSignals
@@ -63,5 +67,5 @@ export async function runConsolidation(store, engine, now = new Date()) {
63
67
  profileRefreshed = true;
64
68
  }
65
69
  saveConfig({ last_consolidation: now.toISOString() });
66
- return { newSignals: newSignals.length, assertions: assertions.length, profileRefreshed };
70
+ return { newSignals: newSignals.length, assertions: assertions.length, profileRefreshed, stylePruned };
67
71
  }
@@ -62,6 +62,12 @@ export function startDaemon(store, port = DEFAULT_PORT) {
62
62
  // Stylistic, not topical — served to every client (it's how you write, not what about).
63
63
  return json(res, 200, store.voice());
64
64
  }
65
+ if (req.method === "DELETE" && url.pathname.startsWith("/voice/")) {
66
+ const [, , dimension, pattern] = url.pathname.split("/");
67
+ if (!dimension || !pattern)
68
+ return json(res, 400, { error: "dimension and pattern required" });
69
+ return json(res, 200, { deleted: store.forgetStyle(dimension, decodeURIComponent(pattern)) });
70
+ }
65
71
  if (req.method === "GET" && url.pathname === "/scopes") {
66
72
  return json(res, 200, loadScopes());
67
73
  }
@@ -133,7 +139,7 @@ export function startDaemon(store, port = DEFAULT_PORT) {
133
139
  try {
134
140
  const engine = await chooseExtractor("extract").catch(() => null);
135
141
  const r = await runConsolidation(store, engine);
136
- console.error(`consolidation: ${r.newSignals} new signals, ${r.assertions} assertions, profile ${r.profileRefreshed ? "refreshed" : "kept"}`);
142
+ console.error(`consolidation: ${r.newSignals} new signals, ${r.assertions} assertions, profile ${r.profileRefreshed ? "refreshed" : "kept"}, ${r.stylePruned} style signals pruned`);
137
143
  }
138
144
  catch (e) {
139
145
  console.error("consolidation failed:", e instanceof Error ? e.message : e);
@@ -96,8 +96,10 @@
96
96
  .voice-groups { display: flex; flex-direction: column; gap: 16px; }
97
97
  .vglabel { font-size: 11px; text-transform: uppercase; letter-spacing: 1.3px; color: var(--dim); margin-bottom: 9px; }
98
98
  .vchips { display: flex; flex-wrap: wrap; gap: 8px; }
99
- .vchip { font-size: 13px; color: var(--text); background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 999px; padding: 5px 13px; }
100
- .vchip i { color: var(--faint); font-style: normal; font-size: 11px; margin-left: 5px; }
99
+ .vchip { font-size: 13px; color: var(--text); background: var(--panel-2); border: 1px solid var(--line-2); border-radius: 999px; padding: 5px 9px 5px 13px; display: inline-flex; align-items: center; gap: 7px; }
100
+ .vchip i { color: var(--faint); font-style: normal; font-size: 11px; }
101
+ .vdel { background: none; border: none; color: var(--faint); cursor: pointer; font-size: 13px; line-height: 1; padding: 0 2px; transition: color .16s; }
102
+ .vdel:hover { color: var(--text); }
101
103
  .evidence { margin-top: 11px; border-top: 1px solid var(--line); padding-top: 11px; display: none; }
102
104
  .evidence.open { display: block; }
103
105
  .ev-item { font-size: 12.5px; color: var(--dim); padding: 5px 0; line-height: 1.5; }
@@ -362,10 +364,18 @@ function renderVoice(voice) {
362
364
  for (const d of order) {
363
365
  if (!groups[d]) continue;
364
366
  html += `<div><div class="vglabel">${esc(label[d])}</div><div class="vchips">` +
365
- groups[d].map((it) => `<span class="vchip" title="${esc(it.evidence || "")}">${esc(it.pattern)}${it.dimension === "emphasis" && it.evidence ? ` <i>${esc(it.evidence)}</i>` : ""}</span>`).join("") +
367
+ groups[d].map((it) => `<span class="vchip" title="${esc(it.evidence || "")}">${esc(it.pattern)}${it.dimension === "emphasis" && it.evidence ? ` <i>${esc(it.evidence)}</i>` : ""}` +
368
+ `<button class="vdel" data-dim="${esc(it.dimension)}" data-pattern="${esc(it.pattern)}" title="Forget this — it won't be re-learned">×</button></span>`).join("") +
366
369
  `</div></div>`;
367
370
  }
368
371
  $("voiceCard").innerHTML = html + `</div>`;
372
+ document.querySelectorAll(".vdel").forEach((b) => b.addEventListener("click", async (ev) => {
373
+ ev.stopPropagation();
374
+ if (!confirm(`Forget "${b.dataset.pattern}"? It won't be re-learned.`)) return;
375
+ if (!DEMO) await fetch(`/voice/${encodeURIComponent(b.dataset.dim)}/${encodeURIComponent(b.dataset.pattern)}`, { method: "DELETE" });
376
+ const v = DEMO ? null : await get("/voice");
377
+ renderVoice(v);
378
+ }));
369
379
  }
370
380
 
371
381
  /* ── receipts ── */
@@ -149,17 +149,25 @@ server.tool("persnally_interests", `Show the user their own tracked interest pro
149
149
  return text(out);
150
150
  }));
151
151
  // ── persnally_forget — privacy control ──────────────────────
152
- 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.`, {
153
- topic: z.string().optional().describe("Topic to remove. Omit with clear_all=true to wipe everything."),
152
+ server.tool("persnally_forget", `Hard-delete a topic or a voice/style pattern (and everything derived from it) from the user's context, or wipe all data. Privacy control — always honor it. A forgotten style pattern stays gone permanently, even if later conversations would otherwise re-observe it.`, {
153
+ topic: z.string().optional().describe("Topic to remove."),
154
+ style: z.object({
155
+ dimension: z.enum(["voice", "convention", "emphasis", "format", "workflow"]),
156
+ pattern: z.string(),
157
+ }).optional().describe("A 'How you write' pattern to remove, e.g. {dimension: 'emphasis', pattern: 'be 100% sure'}."),
154
158
  clear_all: z.boolean().optional().default(false),
155
- }, async ({ topic, clear_all }) => guarded(async () => {
159
+ }, async ({ topic, style, clear_all }) => guarded(async () => {
156
160
  logEvent("tool_call", { tool: "persnally_forget", clear_all });
157
161
  if (clear_all) {
158
162
  await daemonDelete("/events?confirm=all");
159
163
  return text("All Persnally data deleted. The store is empty.");
160
164
  }
165
+ if (style) {
166
+ const r = await daemonDelete(`/voice/${encodeURIComponent(style.dimension)}/${encodeURIComponent(style.pattern)}`);
167
+ return text(r.deleted ? `Forgot "${style.pattern}" — it won't be re-learned.` : `"${style.pattern}" not found.`);
168
+ }
161
169
  if (!topic)
162
- return text("Name a topic to forget, or set clear_all.");
170
+ return text("Name a topic or a style pattern to forget, or set clear_all.");
163
171
  const r = await daemonDelete(`/topics/${encodeURIComponent(topic)}`);
164
172
  return text(r.deleted ? `Deleted ${r.deleted} event(s) for "${topic}", including derived data.` : `"${topic}" not found.`);
165
173
  }));
@@ -31,7 +31,12 @@ function zipHasConversations(zipPath) {
31
31
  return execFileSync("unzip", ["-l", zipPath], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] })
32
32
  .includes("conversations.json");
33
33
  }
34
- catch {
34
+ catch (e) {
35
+ // Only reached on a genuine read failure (unzip missing, corrupt archive,
36
+ // permission denied) — an ordinary non-matching zip never throws here, so
37
+ // this can't spam on unrelated Downloads clutter. Surface it: a real export
38
+ // failing silently is the worst onboarding failure mode there is.
39
+ console.error(`persnally: couldn't read ${zipPath} (${e instanceof Error ? e.message : e}) — skipping`);
35
40
  return false;
36
41
  }
37
42
  }
@@ -54,13 +54,28 @@ export declare class EventStore {
54
54
  rebuild(now?: number): void;
55
55
  saveProfile(p: StoredProfile): void;
56
56
  getProfile(): StoredProfile | null;
57
- /** The voice/convention profile style signals deduped by pattern (newest wins), richest first. */
57
+ /** Logical key for one style pattern stable across re-imports/re-observations. */
58
+ private styleKey;
59
+ /** Patterns the user has explicitly forgotten — a delete correction tombstones the key permanently. */
60
+ private forgottenStyleKeys;
61
+ /** The voice/convention profile — style signals deduped by pattern (newest wins), richest first, forgotten patterns excluded. */
58
62
  voice(): {
59
63
  pack: string;
60
64
  items: StyleSignal[];
61
65
  };
66
+ /**
67
+ * Hard-deletes a style pattern's events and writes a delete correction so it
68
+ * stays gone even if stylometry or live capture re-derives it later — the
69
+ * "deletable for real" promise extended to the voice layer.
70
+ */
71
+ forgetStyle(dimension: string, pattern: string): number;
62
72
  /** Drops style signals of one basis so a deterministic re-run replaces them (live `observed`/`correction` signals are kept). */
63
73
  clearStyleByBasis(basis: string): number;
74
+ /**
75
+ * Consolidation distill: bounds the stored style backlog so live capture
76
+ * never grows unbounded. Keeps the richest signal per pattern, capped overall.
77
+ */
78
+ pruneStyle(maxTotal?: number): number;
64
79
  /** Hard-deletes matching topic events plus derived events referencing them, then rebuilds. */
65
80
  forgetTopic(topic: string): number;
66
81
  /** Removes every event from one import batch — a bad import is fully reversible. */
@@ -6,7 +6,7 @@ import Database from "better-sqlite3";
6
6
  import { mkdirSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import { topicWeight } from "./decay.js";
9
- import { normalizeTopic, validateEvent } from "./events.js";
9
+ import { newEvent, normalizeTopic, validateEvent } from "./events.js";
10
10
  import { DATA_DIR } from "./paths.js";
11
11
  import { assemblePack } from "./stylometry.js";
12
12
  const VIEW_SCHEMA_VERSION = 2;
@@ -196,27 +196,81 @@ export class EventStore {
196
196
  const row = this.db.prepare("SELECT * FROM view_profile WHERE id = 1").get();
197
197
  return row ? { ...row, sections: JSON.parse(row.sections) } : null;
198
198
  }
199
- /** The voice/convention profile style signals deduped by pattern (newest wins), richest first. */
199
+ /** Logical key for one style pattern stable across re-imports/re-observations. */
200
+ styleKey(dimension, pattern) {
201
+ return `style:${dimension}|${pattern.toLowerCase()}`;
202
+ }
203
+ /** Patterns the user has explicitly forgotten — a delete correction tombstones the key permanently. */
204
+ forgottenStyleKeys() {
205
+ const forgotten = new Set();
206
+ for (const e of this.query({ type: "user.correction", limit: 1_000_000 })) {
207
+ const p = e.payload;
208
+ if (p.action === "delete" && p.target_id.startsWith("style:"))
209
+ forgotten.add(p.target_id);
210
+ }
211
+ return forgotten;
212
+ }
213
+ /** The voice/convention profile — style signals deduped by pattern (newest wins), richest first, forgotten patterns excluded. */
200
214
  voice() {
215
+ const forgotten = this.forgottenStyleKeys();
201
216
  const byPattern = new Map();
202
217
  // query() returns ts DESC, so the first occurrence of a pattern is the most recent.
203
218
  for (const e of this.query({ type: "signal.style", limit: 1_000_000 })) {
204
219
  const p = e.payload;
205
- const key = `${p.dimension}|${p.pattern.toLowerCase()}`;
206
- if (!byPattern.has(key))
207
- byPattern.set(key, p);
220
+ const key = this.styleKey(p.dimension, p.pattern);
221
+ if (forgotten.has(key) || byPattern.has(key))
222
+ continue;
223
+ byPattern.set(key, p);
208
224
  }
209
225
  // Cap the served set: live `observed` signals accrue over time, so bound it
210
- // to the richest few (consolidation distills further in Slice 3).
226
+ // to the richest few (consolidation prunes the stored backlog separately).
211
227
  const items = [...byPattern.values()].sort((a, b) => b.confidence - a.confidence).slice(0, 28);
212
228
  return { pack: assemblePack(items), items };
213
229
  }
230
+ /**
231
+ * Hard-deletes a style pattern's events and writes a delete correction so it
232
+ * stays gone even if stylometry or live capture re-derives it later — the
233
+ * "deletable for real" promise extended to the voice layer.
234
+ */
235
+ forgetStyle(dimension, pattern) {
236
+ const key = this.styleKey(dimension, pattern);
237
+ const candidates = this.query({ type: "signal.style", limit: 1_000_000 }).filter((e) => this.styleKey(e.payload.dimension, e.payload.pattern) === key);
238
+ const del = this.db.prepare("DELETE FROM events WHERE id = ?");
239
+ const run = this.db.transaction((toDelete) => { for (const id of toDelete)
240
+ del.run(id); });
241
+ run(candidates.map((e) => e.id));
242
+ this.append([newEvent("user.correction", "dashboard", { target_id: key, action: "delete", reason: "" }, { kind: "local", surface: "dashboard" })]);
243
+ return candidates.length;
244
+ }
214
245
  /** Drops style signals of one basis so a deterministic re-run replaces them (live `observed`/`correction` signals are kept). */
215
246
  clearStyleByBasis(basis) {
216
247
  return this.db
217
248
  .prepare("DELETE FROM events WHERE type = 'signal.style' AND json_extract(payload, '$.basis') = ?")
218
249
  .run(basis).changes;
219
250
  }
251
+ /**
252
+ * Consolidation distill: bounds the stored style backlog so live capture
253
+ * never grows unbounded. Keeps the richest signal per pattern, capped overall.
254
+ */
255
+ pruneStyle(maxTotal = 80) {
256
+ const byPattern = new Map();
257
+ for (const e of this.query({ type: "signal.style", limit: 1_000_000 })) {
258
+ const p = e.payload;
259
+ const key = this.styleKey(p.dimension, p.pattern);
260
+ const existing = byPattern.get(key);
261
+ if (!existing || existing.payload.confidence < p.confidence)
262
+ byPattern.set(key, e);
263
+ }
264
+ const ranked = [...byPattern.entries()].sort((a, b) => b[1].payload.confidence - a[1].payload.confidence);
265
+ const keepIds = new Set(ranked.slice(0, maxTotal).map(([, e]) => e.id));
266
+ const all = this.query({ type: "signal.style", limit: 1_000_000 });
267
+ const toDelete = all.filter((e) => !keepIds.has(e.id)).map((e) => e.id); // drop weaker duplicates + overflow
268
+ const del = this.db.prepare("DELETE FROM events WHERE id = ?");
269
+ const run = this.db.transaction((ids) => { for (const id of ids)
270
+ del.run(id); });
271
+ run(toDelete);
272
+ return toDelete.length;
273
+ }
220
274
  /** Hard-deletes matching topic events plus derived events referencing them, then rebuilds. */
221
275
  forgetTopic(topic) {
222
276
  const key = normalizeTopic(topic);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persnally",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",