persnally 2.0.2 → 2.0.3

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
@@ -384,6 +384,11 @@ async function main() {
384
384
  };
385
385
  process.on("SIGTERM", shutdown);
386
386
  process.on("SIGINT", shutdown);
387
+ // An always-on daemon must not die silently on a stray error. Log a
388
+ // rejection and keep serving; on an uncaught exception the process state
389
+ // is undefined — log and exit so the supervisor (launchd) restarts clean.
390
+ process.on("unhandledRejection", (e) => console.error("unhandledRejection:", e));
391
+ process.on("uncaughtException", (e) => { console.error("uncaughtException:", e); process.exit(1); });
387
392
  console.error(`persnallyd v${VERSION} listening on 127.0.0.1:${port}`);
388
393
  console.error(`Dashboard: http://127.0.0.1:${port}`);
389
394
  return;
@@ -3,7 +3,7 @@
3
3
  * key, so the launchd-run daemon (no shell env) can synthesize. Unknown fields
4
4
  * are preserved (the file predates v2). Saved with owner-only permissions.
5
5
  */
6
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import { DATA_DIR } from "./paths.js";
9
9
  // Resolved at call time so PERSNALLY_DIR overrides work in-process (tests), not just for subprocesses.
@@ -22,9 +22,13 @@ export function saveConfig(updates) {
22
22
  const file = configFile();
23
23
  mkdirSync(dirname(file), { recursive: true });
24
24
  const merged = { ...loadConfig(), ...updates };
25
- // mode on create closes the world-readable window before chmod; chmod also
26
- // corrects perms on a pre-existing file. The config holds the API key.
27
- writeFileSync(file, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
25
+ // Atomic write: a crash mid-write must never leave a truncated config —
26
+ // that would silently drop the API key and all scopes (loadConfig swallows
27
+ // parse errors). Write to a temp file, then rename (atomic on one fs).
28
+ // mode 0600 on create + chmod keeps the key owner-only through the swap.
29
+ const tmp = `${file}.${process.pid}.tmp`;
30
+ writeFileSync(tmp, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
31
+ renameSync(tmp, file);
28
32
  chmodSync(file, 0o600);
29
33
  }
30
34
  /** Env wins over config; sets process.env so the Anthropic SDK picks it up. */
@@ -11,6 +11,8 @@ import { newEvent, validateEvent } from "./events.js";
11
11
  import { chooseExtractor } from "./llm.js";
12
12
  import { synthesizeProfile } from "./profile.js";
13
13
  export const DEFAULT_PORT = 4983;
14
+ const MAX_BODY_BYTES = 25 * 1024 * 1024; // generous for import batches; bounds memory
15
+ const MAX_QUERY_LIMIT = 10_000; // ceiling for public ?limit= params
14
16
  // Single source of truth for the user-visible version: package.json.
15
17
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
16
18
  export const VERSION = pkg.version;
@@ -143,17 +145,33 @@ function dashboardHtml() {
143
145
  return cachedHtml;
144
146
  }
145
147
  function json(res, status, body) {
146
- res.writeHead(status, { "Content-Type": "application/json" });
147
- res.end(JSON.stringify(body));
148
+ // Never throw from the responder — the request socket may already be gone
149
+ // (e.g. an oversized body we destroyed), and a throw here would be unhandled.
150
+ if (res.headersSent || res.writableEnded)
151
+ return;
152
+ try {
153
+ res.writeHead(status, { "Content-Type": "application/json" });
154
+ res.end(JSON.stringify(body));
155
+ }
156
+ catch { /* socket closed mid-response */ }
148
157
  }
149
158
  function num(url, key, fallback) {
150
159
  const v = Number(url.searchParams.get(key));
151
- return Number.isFinite(v) && v > 0 ? v : fallback;
160
+ return Number.isFinite(v) && v > 0 ? Math.min(v, MAX_QUERY_LIMIT) : fallback;
152
161
  }
153
162
  function readBody(req) {
154
163
  return new Promise((resolve, reject) => {
155
164
  let data = "";
156
- req.on("data", (chunk) => (data += chunk));
165
+ let size = 0;
166
+ req.on("data", (chunk) => {
167
+ size += chunk.length;
168
+ if (size > MAX_BODY_BYTES) {
169
+ reject(new Error("request body too large"));
170
+ req.destroy();
171
+ return;
172
+ }
173
+ data += chunk;
174
+ });
157
175
  req.on("end", () => {
158
176
  try {
159
177
  resolve(JSON.parse(data));
@@ -15,7 +15,10 @@ export function topicWeight(signals, now = Date.now()) {
15
15
  let sentiment = 0;
16
16
  const intents = new Map();
17
17
  for (const s of signals) {
18
- const days = Math.max((now - Date.parse(s.ts)) / MS_PER_DAY, 0);
18
+ const parsed = Date.parse(s.ts);
19
+ if (!Number.isFinite(parsed))
20
+ continue; // an unparseable ts must not turn the sum into NaN
21
+ const days = Math.max((now - parsed) / MS_PER_DAY, 0);
19
22
  sum += s.weight * (DEPTH_SCORES[s.depth] ?? 0.3) * Math.exp(-LAMBDA * days);
20
23
  sentiment += SENTIMENT_VALUES[s.sentiment] ?? 0;
21
24
  intents.set(s.intent, (intents.get(s.intent) ?? 0) + 1);
@@ -176,5 +176,9 @@ export declare function validateEvent(raw: unknown): PersnallyEvent;
176
176
  export declare function newEvent(type: EventType, source: string, payload: Record<string, unknown>, provenance: Provenance, occurredAt?: string): PersnallyEvent;
177
177
  /** UUIDv7: 48-bit ms timestamp + random — time-ordered ids that merge cleanly across devices. */
178
178
  export declare function uuidv7(): string;
179
+ /** A valid ISO timestamp from an untrusted value (import dates), or now if it
180
+ can't be parsed — `new Date(junk).toISOString()` throws, which would crash
181
+ a whole import mid-batch after paying for earlier extractions. */
182
+ export declare function safeIso(value: unknown): string;
179
183
  /** Topic normalization, carried over from v1's interest engine (proven merge rules). */
180
184
  export declare function normalizeTopic(topic: string): string;
@@ -120,6 +120,13 @@ export function uuidv7() {
120
120
  const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
121
121
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
122
122
  }
123
+ /** A valid ISO timestamp from an untrusted value (import dates), or now if it
124
+ can't be parsed — `new Date(junk).toISOString()` throws, which would crash
125
+ a whole import mid-batch after paying for earlier extractions. */
126
+ export function safeIso(value) {
127
+ const d = new Date(value);
128
+ return Number.isFinite(d.getTime()) ? d.toISOString() : new Date().toISOString();
129
+ }
123
130
  /** Topic normalization, carried over from v1's interest engine (proven merge rules). */
124
131
  export function normalizeTopic(topic) {
125
132
  let key = topic.toLowerCase().trim();
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { readFileSync, existsSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
+ import { safeIso } from "../events.js";
8
9
  import { anthropicExtract, DEFAULT_EXTRACT_MODEL } from "../llm.js";
9
10
  import { extractEvents } from "./extract.js";
10
11
  export function parseChatGPTExport(path) {
@@ -23,7 +24,7 @@ export function parseChatGPTExport(path) {
23
24
  uuid: String(c.conversation_id ?? c.id ?? ""),
24
25
  name: String(c.title ?? ""),
25
26
  summary: "",
26
- created_at: c.create_time ? new Date(c.create_time * 1000).toISOString() : new Date().toISOString(),
27
+ created_at: safeIso(c.create_time ? c.create_time * 1000 : undefined),
27
28
  userMessages,
28
29
  };
29
30
  });
@@ -3,7 +3,7 @@
3
3
  * Parsers produce a ParsedExport; this turns it into provenance-linked events.
4
4
  */
5
5
  import { z } from "zod";
6
- import { newEvent, uuidv7, PAYLOAD_SCHEMAS } from "../events.js";
6
+ import { newEvent, safeIso, uuidv7, PAYLOAD_SCHEMAS } from "../events.js";
7
7
  import { anthropicExtract, DEFAULT_EXTRACT_MODEL } from "../llm.js";
8
8
  const MAX_CONVO_CHARS = 30_000;
9
9
  const topicsExtraction = z.object({ topics: z.array(PAYLOAD_SCHEMAS["signal.topic"]) });
@@ -23,7 +23,7 @@ export async function extractEvents(parsed, opts, extract = anthropicExtract, mo
23
23
  });
24
24
  const { topics } = topicsExtraction.parse(result);
25
25
  for (const t of topics) {
26
- events.push(newEvent("signal.topic", opts.source, t, { kind: "import", batch, file: opts.file, conversation_uuid: convo.uuid }, new Date(convo.created_at).toISOString()));
26
+ events.push(newEvent("signal.topic", opts.source, t, { kind: "import", batch, file: opts.file, conversation_uuid: convo.uuid }, safeIso(convo.created_at)));
27
27
  }
28
28
  }
29
29
  if (parsed.memoryText.trim() || parsed.projects.length) {
@@ -4,7 +4,7 @@
4
4
  * Carries forward the v1 skill_analyzer's framework-detection approach.
5
5
  */
6
6
  import { execFileSync } from "node:child_process";
7
- import { existsSync, readFileSync, readdirSync } from "node:fs";
7
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
8
8
  import { basename, join } from "node:path";
9
9
  import { newEvent, uuidv7 } from "../events.js";
10
10
  const FRAMEWORKS = {
@@ -81,6 +81,10 @@ export function scanRepos(path, authorEmail) {
81
81
  const direct = summarizeRepo(path, authorEmail);
82
82
  if (direct)
83
83
  return [direct];
84
+ // Not a repo — must be a folder of repos. A file path here is user error,
85
+ // not a crash (readdirSync would throw ENOTDIR).
86
+ if (!existsSync(path) || !statSync(path).isDirectory())
87
+ return [];
84
88
  return readdirSync(path, { withFileTypes: true })
85
89
  .filter((d) => d.isDirectory())
86
90
  .map((d) => { try {
@@ -3,7 +3,7 @@
3
3
  * The pidfile is advisory: a stale one (dead pid) is detected and cleaned up.
4
4
  */
5
5
  import { execFileSync, spawn } from "node:child_process";
6
- import { existsSync, mkdirSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
6
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { DATA_DIR } from "./paths.js";
@@ -49,6 +49,7 @@ export async function startDetached(cliPath, port) {
49
49
  stdio: ["ignore", log, log],
50
50
  env: process.env,
51
51
  });
52
+ closeSync(log); // the child dup'd it at spawn; don't leak the parent's copy
52
53
  child.unref();
53
54
  for (let i = 0; i < 30; i++) {
54
55
  await sleep(100);
@@ -6,6 +6,7 @@
6
6
  import { existsSync, readFileSync, renameSync } from "fs";
7
7
  import { homedir } from "os";
8
8
  import { join } from "path";
9
+ import { safeIso } from "../events.js";
9
10
  import { daemonPost } from "./daemon-client.js";
10
11
  const GRAPH_FILE = join(homedir(), ".persnally", "interest-graph.json");
11
12
  const INTENTS = new Set(["learning", "building", "researching", "deciding", "discussing", "debugging"]);
@@ -21,6 +22,11 @@ export async function migrateV1Graph() {
21
22
  nodes = JSON.parse(readFileSync(GRAPH_FILE, "utf-8")).nodes ?? {};
22
23
  }
23
24
  catch {
25
+ // Corrupt v1 file — move it aside so we don't reparse it on every session.
26
+ try {
27
+ renameSync(GRAPH_FILE, GRAPH_FILE + ".v1-corrupt");
28
+ }
29
+ catch { /* leave it */ }
24
30
  return 0;
25
31
  }
26
32
  const entries = Object.values(nodes);
@@ -29,7 +35,7 @@ export async function migrateV1Graph() {
29
35
  const events = entries.map((n) => ({
30
36
  type: "signal.topic",
31
37
  source: "system",
32
- ts: new Date(n.last_seen).toISOString(),
38
+ ts: safeIso(n.last_seen),
33
39
  payload: {
34
40
  topic: n.topic,
35
41
  weight: Math.min(Math.max(n.current_weight, 0.05), 1),
@@ -9,11 +9,17 @@ import { homedir, tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { loadConfig, saveConfig } from "./config.js";
11
11
  function sniffKind(conversationsJson) {
12
+ // 64 KB: the discriminating key can sit past a large first record (esp. ChatGPT).
12
13
  const fd = openSync(conversationsJson, "r");
13
- const buf = Buffer.alloc(4096);
14
- const n = readSync(fd, buf, 0, buf.length, 0);
15
- closeSync(fd);
16
- const head = buf.toString("utf-8", 0, n);
14
+ let head;
15
+ try {
16
+ const buf = Buffer.alloc(65536);
17
+ const n = readSync(fd, buf, 0, buf.length, 0);
18
+ head = buf.toString("utf-8", 0, n);
19
+ }
20
+ finally {
21
+ closeSync(fd); // always close, even if readSync throws
22
+ }
17
23
  if (head.includes('"chat_messages"'))
18
24
  return "claude";
19
25
  if (head.includes('"mapping"'))
@@ -16,6 +16,10 @@ export class EventStore {
16
16
  mkdirSync(dirname(path), { recursive: true });
17
17
  this.db = new Database(path);
18
18
  this.db.pragma("journal_mode = WAL");
19
+ // The CLI and the daemon each open their own connection; a blocked writer
20
+ // waits instead of failing fast with SQLITE_BUSY (better-sqlite3 defaults
21
+ // to 5s — set explicitly with headroom for large rebuilds).
22
+ this.db.pragma("busy_timeout = 10000");
19
23
  this.migrate();
20
24
  }
21
25
  migrate() {
@@ -162,7 +166,9 @@ export class EventStore {
162
166
  topic: a.topic,
163
167
  category: [...a.categories.entries()].sort((x, y) => y[1] - x[1])[0][0],
164
168
  signals: a.signals.length,
165
- weight: w.weight,
169
+ // Guard the NOT NULL column: a non-finite weight would abort the whole
170
+ // rebuild transaction and wedge the topic view permanently.
171
+ weight: Number.isFinite(w.weight) ? w.weight : 0,
166
172
  sentiment_balance: w.sentiment_balance,
167
173
  dominant_intent: w.dominant_intent,
168
174
  entities: [...a.entities].slice(0, 20),
@@ -217,7 +223,9 @@ export class EventStore {
217
223
  return result.changes;
218
224
  }
219
225
  forgetAll() {
220
- this.db.exec("DELETE FROM events; DELETE FROM view_topics;");
226
+ // Clear the profile too — it's prose derived from now-deleted events.
227
+ // Leaving it would serve a profile after a full wipe ("deletable for real").
228
+ this.db.exec("DELETE FROM events; DELETE FROM view_topics; DELETE FROM view_profile;");
221
229
  }
222
230
  close() {
223
231
  this.db.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persnally",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "license": "FSL-1.1-MIT",
5
5
  "description": "The context engine for you — local-first, across every AI. So every AI finally knows you.",
6
6
  "type": "module",