persnally 2.1.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
@@ -20,6 +20,8 @@ import { DEFAULT_TRANSCRIPTS_DIR, extractClaudeCodeEvents, parseClaudeCodeTransc
20
20
  import { gitEvents, scanRepos } from "./importers/git.js";
21
21
  import { autostartInstalled, installAutostart, LOG_FILE, removeAutostart, removePidFile, runningPid, startDetached, stopDaemon, writePidFile, } from "./lifecycle.js";
22
22
  import { newEvent } from "./events.js";
23
+ import { proseLines } from "./prose.js";
24
+ import { analyzeVoice } from "./stylometry.js";
23
25
  import { renderProfile, synthesizeProfile } from "./profile.js";
24
26
  import { DEFAULT_DB_PATH, EventStore } from "./store.js";
25
27
  const USAGE = `persnallyd ${VERSION} — so every AI finally knows you
@@ -35,10 +37,12 @@ Usage:
35
37
  persnallyd import chatgpt <path> Import a ChatGPT export dir or conversations.json (needs ANTHROPIC_API_KEY)
36
38
  persnallyd import git <path> [--author <email>] Import repo activity (offline, no LLM); path = repo or folder of repos
37
39
  persnallyd profile Synthesize your profile from the store
40
+ persnallyd voice Refresh your voice fingerprint from Claude Code transcripts (offline, no LLM)
38
41
  persnallyd consolidate Reflect now: refresh decay, add behavior patterns, re-synthesize
39
42
  persnallyd show [topics|events|profile] Show topics (default), recent events, or the profile
40
43
  persnallyd context [--full] Emit profile + interests for AI injection (records a context read)
41
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
42
46
  persnallyd forget --all Delete all data
43
47
  persnallyd forget --batch <id> Undo one import batch
44
48
  persnallyd status Store stats and daemon health
@@ -291,7 +295,7 @@ async function main() {
291
295
  const store = new EventStore();
292
296
  const r = await runConsolidation(store, engine);
293
297
  store.close();
294
- 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.`);
295
299
  return;
296
300
  }
297
301
  case "profile": {
@@ -303,6 +307,21 @@ async function main() {
303
307
  console.log(renderProfile(profile));
304
308
  return;
305
309
  }
310
+ case "voice": {
311
+ // Deterministic, offline, re-runnable — refreshes the stylometry layer in place.
312
+ const dir = args[0] || DEFAULT_TRANSCRIPTS_DIR;
313
+ const { parsed } = parseClaudeCodeTranscripts(dir);
314
+ const corpus = parsed.conversations.flatMap((c) => proseLines(c.userMessages.join("\n")));
315
+ const v = analyzeVoice(corpus);
316
+ if (!v.signals.length)
317
+ return die(`Not enough prose in ${dir} to fingerprint a voice yet.`);
318
+ const store = new EventStore();
319
+ store.clearStyleByBasis("stylometry"); // replace, don't accumulate, across re-runs
320
+ store.append(v.signals.map((s) => newEvent("signal.style", "cli", s, { kind: "local", surface: "cli" })));
321
+ store.close();
322
+ console.log(`Voice fingerprint refreshed from ${v.prompts} prompts.\n\n${v.pack}`);
323
+ return;
324
+ }
306
325
  case "show": {
307
326
  const store = new EventStore();
308
327
  if (args[0] === "profile") {
@@ -378,11 +397,15 @@ async function main() {
378
397
  else if (args[0] === "--batch" && args[1]) {
379
398
  console.log(`Deleted ${store.forgetBatch(args[1])} events from batch ${args[1]}.`);
380
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
+ }
381
404
  else if (args[0]) {
382
405
  console.log(`Deleted ${store.forgetTopic(args[0])} events for "${args[0]}".`);
383
406
  }
384
407
  else {
385
- die("usage: persnallyd forget <topic> | --all | --batch <id>");
408
+ die("usage: persnallyd forget <topic> | --all | --batch <id> | --style <dimension> <pattern>");
386
409
  }
387
410
  store.close();
388
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
  }
@@ -58,6 +58,16 @@ export function startDaemon(store, port = DEFAULT_PORT) {
58
58
  const profile = store.getProfile();
59
59
  return profile ? json(res, 200, profile) : json(res, 404, { error: "no profile synthesized yet" });
60
60
  }
61
+ if (req.method === "GET" && url.pathname === "/voice") {
62
+ // Stylistic, not topical — served to every client (it's how you write, not what about).
63
+ return json(res, 200, store.voice());
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
+ }
61
71
  if (req.method === "GET" && url.pathname === "/scopes") {
62
72
  return json(res, 200, loadScopes());
63
73
  }
@@ -129,7 +139,7 @@ export function startDaemon(store, port = DEFAULT_PORT) {
129
139
  try {
130
140
  const engine = await chooseExtractor("extract").catch(() => null);
131
141
  const r = await runConsolidation(store, engine);
132
- 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`);
133
143
  }
134
144
  catch (e) {
135
145
  console.error("consolidation failed:", e instanceof Error ? e.message : e);