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 +7 -2
- package/build/src/consolidate.d.ts +1 -0
- package/build/src/consolidate.js +5 -1
- package/build/src/daemon.js +7 -1
- package/build/src/dashboard.html +13 -3
- package/build/src/mcp/index.js +12 -4
- package/build/src/setup.js +6 -1
- package/build/src/store.d.ts +16 -1
- package/build/src/store.js +60 -6
- package/package.json +1 -1
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;
|
package/build/src/consolidate.js
CHANGED
|
@@ -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
|
}
|
package/build/src/daemon.js
CHANGED
|
@@ -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);
|
package/build/src/dashboard.html
CHANGED
|
@@ -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;
|
|
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>` : ""}
|
|
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 ── */
|
package/build/src/mcp/index.js
CHANGED
|
@@ -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.
|
|
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
|
}));
|
package/build/src/setup.js
CHANGED
|
@@ -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
|
}
|
package/build/src/store.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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. */
|
package/build/src/store.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 =
|
|
206
|
-
if (
|
|
207
|
-
|
|
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
|
|
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);
|