persnally 2.0.2 → 2.1.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 +72 -2
- package/build/src/config.js +8 -4
- package/build/src/connect.d.ts +7 -0
- package/build/src/connect.js +38 -3
- package/build/src/daemon.js +22 -4
- package/build/src/decay.js +4 -1
- package/build/src/events.d.ts +4 -0
- package/build/src/events.js +7 -0
- package/build/src/importers/chatgpt.js +2 -1
- package/build/src/importers/extract.js +2 -2
- package/build/src/importers/git.js +5 -1
- package/build/src/lifecycle.js +2 -1
- package/build/src/mcp/index.js +0 -0
- package/build/src/mcp/migrate-v1.js +7 -1
- package/build/src/setup.js +10 -4
- package/build/src/store.js +10 -2
- package/package.json +2 -2
package/build/src/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import { existsSync, rmSync } from "node:fs";
|
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
import { applyApiKey, configPath, loadConfig, saveConfig } from "./config.js";
|
|
11
|
-
import { CLIENTS, connectAll, connectClient } from "./connect.js";
|
|
11
|
+
import { CLIENTS, connectAll, connectClient, installClaudeCodeHook } from "./connect.js";
|
|
12
12
|
import { runConsolidation } from "./consolidate.js";
|
|
13
13
|
import { chooseExtractor } from "./llm.js";
|
|
14
14
|
import { CATEGORIES, clearScope, loadScopes, setScope } from "./permissions.js";
|
|
@@ -19,6 +19,7 @@ import { extractClaudeEvents, parseClaudeExport } from "./importers/claude.js";
|
|
|
19
19
|
import { DEFAULT_TRANSCRIPTS_DIR, extractClaudeCodeEvents, parseClaudeCodeTranscripts, } from "./importers/claude-code.js";
|
|
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
|
+
import { newEvent } from "./events.js";
|
|
22
23
|
import { renderProfile, synthesizeProfile } from "./profile.js";
|
|
23
24
|
import { DEFAULT_DB_PATH, EventStore } from "./store.js";
|
|
24
25
|
const USAGE = `persnallyd ${VERSION} — so every AI finally knows you
|
|
@@ -36,6 +37,7 @@ Usage:
|
|
|
36
37
|
persnallyd profile Synthesize your profile from the store
|
|
37
38
|
persnallyd consolidate Reflect now: refresh decay, add behavior patterns, re-synthesize
|
|
38
39
|
persnallyd show [topics|events|profile] Show topics (default), recent events, or the profile
|
|
40
|
+
persnallyd context [--full] Emit profile + interests for AI injection (records a context read)
|
|
39
41
|
persnallyd forget <topic> Hard-delete a topic and everything derived from it
|
|
40
42
|
persnallyd forget --all Delete all data
|
|
41
43
|
persnallyd forget --batch <id> Undo one import batch
|
|
@@ -150,9 +152,19 @@ async function main() {
|
|
|
150
152
|
}
|
|
151
153
|
store.close();
|
|
152
154
|
// 6. AI clients
|
|
153
|
-
|
|
155
|
+
const connections = connectAll();
|
|
156
|
+
for (const { client, file } of connections) {
|
|
154
157
|
console.log(file ? `✓ Connected ${client}` : `· ${client} not installed — skipped`);
|
|
155
158
|
}
|
|
159
|
+
if (connections.some((r) => r.client === "claude-code" && r.file)) {
|
|
160
|
+
try {
|
|
161
|
+
installClaudeCodeHook();
|
|
162
|
+
console.log("✓ Context hook installed (injects on every Claude Code session)");
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
console.error(`· Context hook skipped: ${e instanceof Error ? e.message : e}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
156
168
|
console.log(`\nDone${imported ? ` — ${imported} events imported` : ""}. Dashboard: http://127.0.0.1:${port}`);
|
|
157
169
|
if (process.platform === "darwin" && process.stdout.isTTY) {
|
|
158
170
|
try {
|
|
@@ -197,6 +209,15 @@ async function main() {
|
|
|
197
209
|
for (const { client, file } of results) {
|
|
198
210
|
console.log(file ? `Connected ${client} (${file})` : `${client} not installed — skipped`);
|
|
199
211
|
}
|
|
212
|
+
// Claude Code also gets a SessionStart hook so every session injects context automatically.
|
|
213
|
+
if (results.some((r) => r.client === "claude-code" && r.file)) {
|
|
214
|
+
try {
|
|
215
|
+
console.log(`Installed Claude Code context hook (${installClaudeCodeHook()})`);
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
console.error(`Context hook not installed: ${e instanceof Error ? e.message : e}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
200
221
|
return;
|
|
201
222
|
}
|
|
202
223
|
case "config": {
|
|
@@ -304,6 +325,50 @@ async function main() {
|
|
|
304
325
|
store.close();
|
|
305
326
|
return;
|
|
306
327
|
}
|
|
328
|
+
case "context": {
|
|
329
|
+
// Serving path for the SessionStart hook: emit profile + interests for
|
|
330
|
+
// injection AND record the read, so hook injections count toward the
|
|
331
|
+
// context-reads metric exactly like the MCP persnally_context tool. `show`
|
|
332
|
+
// stays side-effect-free so manual inspection never inflates the metric.
|
|
333
|
+
const full = args.includes("--full");
|
|
334
|
+
const hook = args.includes("--hook");
|
|
335
|
+
const store = new EventStore();
|
|
336
|
+
const profile = store.getProfile();
|
|
337
|
+
const topics = store.topics(full ? 25 : 12);
|
|
338
|
+
if (!profile && !topics.length) {
|
|
339
|
+
store.close();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const out = [];
|
|
343
|
+
let items = topics.length;
|
|
344
|
+
if (profile) {
|
|
345
|
+
out.push("# About the user", profile.headline, "");
|
|
346
|
+
const sections = full ? profile.sections : profile.sections.slice(0, 3);
|
|
347
|
+
items += sections.length;
|
|
348
|
+
for (const s of sections)
|
|
349
|
+
out.push(`## ${s.title}`, s.body, "");
|
|
350
|
+
}
|
|
351
|
+
if (topics.length) {
|
|
352
|
+
out.push("# Current interests (decay-weighted)");
|
|
353
|
+
for (const t of topics) {
|
|
354
|
+
out.push(`- ${t.topic} (${t.category}, ${t.dominant_intent}, weight ${t.weight.toFixed(2)})`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// Recording must never break the injection itself (mirrors MCP recordRead).
|
|
358
|
+
try {
|
|
359
|
+
store.append([newEvent("context.read", "cli", { scope: full ? "full" : "brief", client_purpose: hook ? "session-start hook" : "cli context read", items }, { kind: "local", surface: "cli" })]);
|
|
360
|
+
}
|
|
361
|
+
catch (e) {
|
|
362
|
+
console.error("persnally: context.read not recorded:", e instanceof Error ? e.message : e);
|
|
363
|
+
}
|
|
364
|
+
store.close();
|
|
365
|
+
const rendered = out.join("\n");
|
|
366
|
+
// --hook emits the SessionStart envelope itself, so the installed hook needs no jq.
|
|
367
|
+
console.log(hook
|
|
368
|
+
? JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: rendered } })
|
|
369
|
+
: rendered);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
307
372
|
case "forget": {
|
|
308
373
|
const store = new EventStore();
|
|
309
374
|
if (args[0] === "--all") {
|
|
@@ -384,6 +449,11 @@ async function main() {
|
|
|
384
449
|
};
|
|
385
450
|
process.on("SIGTERM", shutdown);
|
|
386
451
|
process.on("SIGINT", shutdown);
|
|
452
|
+
// An always-on daemon must not die silently on a stray error. Log a
|
|
453
|
+
// rejection and keep serving; on an uncaught exception the process state
|
|
454
|
+
// is undefined — log and exit so the supervisor (launchd) restarts clean.
|
|
455
|
+
process.on("unhandledRejection", (e) => console.error("unhandledRejection:", e));
|
|
456
|
+
process.on("uncaughtException", (e) => { console.error("uncaughtException:", e); process.exit(1); });
|
|
387
457
|
console.error(`persnallyd v${VERSION} listening on 127.0.0.1:${port}`);
|
|
388
458
|
console.error(`Dashboard: http://127.0.0.1:${port}`);
|
|
389
459
|
return;
|
package/build/src/config.js
CHANGED
|
@@ -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
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
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. */
|
package/build/src/connect.d.ts
CHANGED
|
@@ -7,6 +7,13 @@ export type Client = (typeof CLIENTS)[number];
|
|
|
7
7
|
export declare function mcpServerPath(): string;
|
|
8
8
|
/** Returns the config file written, or null when the client isn't installed. */
|
|
9
9
|
export declare function connectClient(client: Client): string | null;
|
|
10
|
+
/**
|
|
11
|
+
* Installs (or upgrades) the Persnally SessionStart hook in Claude Code's user
|
|
12
|
+
* settings so every session injects the user's context. Merges into existing
|
|
13
|
+
* settings, leaves other tools' hooks untouched, and is idempotent: a prior
|
|
14
|
+
* Persnally entry (including the old `show topics` form) is replaced, not duplicated.
|
|
15
|
+
*/
|
|
16
|
+
export declare function installClaudeCodeHook(): string;
|
|
10
17
|
export declare function connectAll(): {
|
|
11
18
|
client: Client;
|
|
12
19
|
file: string | null;
|
package/build/src/connect.js
CHANGED
|
@@ -2,10 +2,17 @@
|
|
|
2
2
|
* Writes the Persnally MCP server into AI clients' configs.
|
|
3
3
|
* Only touches clients that are actually installed; merges, never clobbers.
|
|
4
4
|
*/
|
|
5
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { dirname, join } from "node:path";
|
|
8
8
|
export const CLIENTS = ["claude-code", "claude-desktop", "cursor"];
|
|
9
|
+
/** Write JSON via temp file + rename: a crash mid-write can't corrupt the user's config. */
|
|
10
|
+
function writeJsonAtomic(file, cfg) {
|
|
11
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
12
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
13
|
+
writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n");
|
|
14
|
+
renameSync(tmp, file);
|
|
15
|
+
}
|
|
9
16
|
function configPathFor(client) {
|
|
10
17
|
const home = homedir();
|
|
11
18
|
switch (client) {
|
|
@@ -50,8 +57,36 @@ export function connectClient(client) {
|
|
|
50
57
|
}
|
|
51
58
|
const servers = (cfg.mcpServers ??= {});
|
|
52
59
|
servers.persnally = { command: "node", args: [mcpServerPath()] };
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
writeJsonAtomic(file, cfg);
|
|
61
|
+
return file;
|
|
62
|
+
}
|
|
63
|
+
// The hook self-renders the SessionStart envelope (`context --hook`), so no jq dependency.
|
|
64
|
+
const SESSION_START_COMMAND = "persnallyd context --hook 2>/dev/null";
|
|
65
|
+
/**
|
|
66
|
+
* Installs (or upgrades) the Persnally SessionStart hook in Claude Code's user
|
|
67
|
+
* settings so every session injects the user's context. Merges into existing
|
|
68
|
+
* settings, leaves other tools' hooks untouched, and is idempotent: a prior
|
|
69
|
+
* Persnally entry (including the old `show topics` form) is replaced, not duplicated.
|
|
70
|
+
*/
|
|
71
|
+
export function installClaudeCodeHook() {
|
|
72
|
+
const file = join(homedir(), ".claude", "settings.json");
|
|
73
|
+
let cfg = {};
|
|
74
|
+
if (existsSync(file)) {
|
|
75
|
+
try {
|
|
76
|
+
cfg = JSON.parse(readFileSync(file, "utf-8"));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new Error(`${file} is not valid JSON — fix it, then run \`persnallyd connect claude-code\` again`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const hooks = (cfg.hooks ??= {});
|
|
83
|
+
const existing = Array.isArray(hooks.SessionStart) ? hooks.SessionStart : [];
|
|
84
|
+
const others = existing.filter((g) => !g.hooks?.some((h) => /persnall/i.test(h.command ?? "")));
|
|
85
|
+
others.push({
|
|
86
|
+
hooks: [{ type: "command", command: SESSION_START_COMMAND, timeout: 10, statusMessage: "Loading your Persnally context…" }],
|
|
87
|
+
});
|
|
88
|
+
hooks.SessionStart = others;
|
|
89
|
+
writeJsonAtomic(file, cfg);
|
|
55
90
|
return file;
|
|
56
91
|
}
|
|
57
92
|
export function connectAll() {
|
package/build/src/daemon.js
CHANGED
|
@@ -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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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));
|
package/build/src/decay.js
CHANGED
|
@@ -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
|
|
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);
|
package/build/src/events.d.ts
CHANGED
|
@@ -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;
|
package/build/src/events.js
CHANGED
|
@@ -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 ?
|
|
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 },
|
|
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 {
|
package/build/src/lifecycle.js
CHANGED
|
@@ -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);
|
package/build/src/mcp/index.js
CHANGED
|
File without changes
|
|
@@ -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:
|
|
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),
|
package/build/src/setup.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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"'))
|
package/build/src/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "persnally",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"license": "FSL-1.1-MIT",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Your own context engine — local-first, across every AI. So every AI finally knows you.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"persnally": "build/src/cli.js",
|