persnally 2.0.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/LICENSE +110 -0
- package/README.md +96 -0
- package/build/src/cli.d.ts +6 -0
- package/build/src/cli.js +404 -0
- package/build/src/config.d.ts +10 -0
- package/build/src/config.js +42 -0
- package/build/src/connect.d.ts +13 -0
- package/build/src/connect.js +51 -0
- package/build/src/consolidate.d.ts +18 -0
- package/build/src/consolidate.js +67 -0
- package/build/src/daemon.d.ts +9 -0
- package/build/src/daemon.js +167 -0
- package/build/src/dashboard.html +181 -0
- package/build/src/decay.d.ts +19 -0
- package/build/src/decay.js +33 -0
- package/build/src/events.d.ts +180 -0
- package/build/src/events.js +133 -0
- package/build/src/importers/chatgpt.d.ts +9 -0
- package/build/src/importers/chatgpt.js +34 -0
- package/build/src/importers/claude-code.d.ts +16 -0
- package/build/src/importers/claude-code.js +99 -0
- package/build/src/importers/claude.d.ts +8 -0
- package/build/src/importers/claude.js +52 -0
- package/build/src/importers/extract.d.ts +31 -0
- package/build/src/importers/extract.js +53 -0
- package/build/src/importers/git.d.ts +23 -0
- package/build/src/importers/git.js +123 -0
- package/build/src/lifecycle.d.ts +14 -0
- package/build/src/lifecycle.js +119 -0
- package/build/src/llm.d.ts +25 -0
- package/build/src/llm.js +76 -0
- package/build/src/mcp/daemon-client.d.ts +11 -0
- package/build/src/mcp/daemon-client.js +42 -0
- package/build/src/mcp/index.d.ts +10 -0
- package/build/src/mcp/index.js +158 -0
- package/build/src/mcp/migrate-v1.d.ts +6 -0
- package/build/src/mcp/migrate-v1.js +48 -0
- package/build/src/mcp/telemetry.d.ts +8 -0
- package/build/src/mcp/telemetry.js +29 -0
- package/build/src/paths.d.ts +2 -0
- package/build/src/paths.js +4 -0
- package/build/src/permissions.d.ts +14 -0
- package/build/src/permissions.js +33 -0
- package/build/src/profile.d.ts +22 -0
- package/build/src/profile.js +62 -0
- package/build/src/setup.d.ts +23 -0
- package/build/src/setup.js +111 -0
- package/build/src/store.d.ts +62 -0
- package/build/src/store.js +233 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Functional Source License, Version 1.1, MIT Future License
|
|
2
|
+
|
|
3
|
+
## Abbreviation
|
|
4
|
+
|
|
5
|
+
FSL-1.1-MIT
|
|
6
|
+
|
|
7
|
+
## Notice
|
|
8
|
+
|
|
9
|
+
Copyright 2026 Persnally
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
22
|
+
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the MIT license that is effective on the second anniversary of the date we make
|
|
91
|
+
the Software available. On or after that date, you may use the Software under
|
|
92
|
+
the MIT license, in which case the following will apply:
|
|
93
|
+
|
|
94
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
95
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
96
|
+
the Software without restriction, including without limitation the rights to
|
|
97
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
98
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
99
|
+
so, subject to the following conditions:
|
|
100
|
+
|
|
101
|
+
The above copyright notice and this permission notice shall be included in all
|
|
102
|
+
copies or substantial portions of the Software.
|
|
103
|
+
|
|
104
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
105
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
106
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
107
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
108
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
109
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
110
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Persnally
|
|
2
|
+
|
|
3
|
+
[](https://github.com/sidpan2011/persnally/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**So every AI finally knows you.**
|
|
6
|
+
|
|
7
|
+
Persnally is a local-first personal context engine. It learns who you are from your AI activity — your Claude and ChatGPT history, your code — and serves that context to every AI tool you use, so they stop treating you like a stranger.
|
|
8
|
+
|
|
9
|
+
Your context lives on your machine. Not in our cloud, not in any model vendor's silo. You can read every byte, see why it believes each thing, and delete any of it.
|
|
10
|
+
|
|
11
|
+
> **The giants build the intelligence. Persnally makes it yours.**
|
|
12
|
+
|
|
13
|
+
## Why
|
|
14
|
+
|
|
15
|
+
Every AI you use is brilliant and amnesiac. ChatGPT doesn't know what you told Claude. Your coding agent doesn't know your stack or your tolerances. Each one relearns you from zero, every session — or interrupts you to ask.
|
|
16
|
+
|
|
17
|
+
The fix isn't a better model. It's a layer underneath all of them that holds *you*: your interests, your projects, how you decide, what you're avoiding. The model vendors won't build this — they can't share your context with each other, and their business is keeping you inside their walls. So it has to be neutral, and it has to be yours.
|
|
18
|
+
|
|
19
|
+
## The five-minute wow
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install -g persnally
|
|
23
|
+
persnallyd start # the local daemon
|
|
24
|
+
persnallyd import claude ~/Downloads/<your-claude-export>
|
|
25
|
+
persnallyd import git ~/Projects # offline, no API needed
|
|
26
|
+
persnallyd profile # synthesize who you are
|
|
27
|
+
open http://127.0.0.1:4983 # see it, with evidence for every claim
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Export your data ([claude.ai](https://claude.com) / [chatgpt.com](https://chatgpt.com) → Settings → Data export), point Persnally at it, and read a description of yourself that's sharper than your own bio — every sentence traceable to the conversations it came from.
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Your AI clients (Claude, Cursor, agents…) Importers (claude · chatgpt · git)
|
|
36
|
+
│ MCP: context out, signals in │ your history → events
|
|
37
|
+
▼ ▼
|
|
38
|
+
┌──────────────────────── persnallyd (local daemon) ────────────────────────┐
|
|
39
|
+
│ Append-only event log (SQLite) — the single source of truth │
|
|
40
|
+
│ → extractors (decay-weighted interests, assertions, skills) │
|
|
41
|
+
│ → derived views (always re-derivable, every claim cites its events) │
|
|
42
|
+
└───────────────────────────────┬───────────────────────────────────────────┘
|
|
43
|
+
loopback only · dashboard · CLI · MCP server
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **Event-sourced.** Everything is an append-only event; the profile and interest graph are *derived views* you can rebuild or delete at will.
|
|
47
|
+
- **Provenance-complete.** Every claim in your profile links to the exact events behind it — the dashboard's "why does it think this?" is a real answer, not a guess.
|
|
48
|
+
- **Truly deletable.** `persnallyd forget <topic>` hard-deletes the events *and* everything derived from them. No tombstones, no residue.
|
|
49
|
+
- **Deterministic reads.** Serving context to an AI never calls a model — it's instant, free, and works offline. Models run only at import and synthesis.
|
|
50
|
+
|
|
51
|
+
## Make your AI tools use it
|
|
52
|
+
|
|
53
|
+
Add the MCP server to any client (Claude Desktop, Cursor, Claude Code). It exposes four tools backed by the daemon:
|
|
54
|
+
|
|
55
|
+
| Tool | What it does |
|
|
56
|
+
|------|-------------|
|
|
57
|
+
| `persnally_context` | Returns who you are + current interests, for the AI to use |
|
|
58
|
+
| `persnally_track` | Records signals from the conversation (topics, decisions, preferences) |
|
|
59
|
+
| `persnally_interests` | Shows you your own tracked profile |
|
|
60
|
+
| `persnally_forget` | Deletes a topic, or wipes everything |
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
// e.g. Claude Desktop — claude_desktop_config.json
|
|
64
|
+
{ "mcpServers": { "persnally": { "command": "persnally-mcp" } } }
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Your data, your rules
|
|
68
|
+
|
|
69
|
+
- **Local-first.** State lives in `~/.persnally`. Nothing leaves your machine except, at import/synthesis, the text you choose to send to your own LLM for extraction (bring your own key).
|
|
70
|
+
- **Structured signals only.** Raw conversations are never stored — only `{ topic, weight, intent, sentiment, category, … }` and provenance pointers.
|
|
71
|
+
- **Inspectable & deletable.** The dashboard shows everything; the delete button means it.
|
|
72
|
+
- **Source-available.** Read the engine, audit the claims, run it yourself.
|
|
73
|
+
|
|
74
|
+
## CLI
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
persnallyd start | stop | status # daemon lifecycle
|
|
78
|
+
persnallyd autostart [--remove] # run at login (macOS)
|
|
79
|
+
persnallyd import claude|chatgpt|git <path>
|
|
80
|
+
persnallyd profile # synthesize the profile
|
|
81
|
+
persnallyd show [topics|events|profile]
|
|
82
|
+
persnallyd forget <topic> | --all | --batch <id>
|
|
83
|
+
persnallyd config set-key <sk-ant-…> # key for the background daemon
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Status
|
|
87
|
+
|
|
88
|
+
Early and moving fast — see [ROADMAP.md](./ROADMAP.md). Today: import from Claude/ChatGPT/git, a decay-weighted interest graph, an evidence-linked profile, a local dashboard, and the MCP layer that serves it all. Next: cross-tool context everywhere, then a behavior model that can answer *what would I do here?*
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
[FSL-1.1-MIT](./LICENSE) — read it, audit it, run it, fork it for anything except reselling it as a competing service. Every release automatically becomes plain MIT two years after it ships. The [event schema](./docs/EVENT_SCHEMA.md) and MCP interface are an open spec — build against them freely.
|
|
93
|
+
|
|
94
|
+
## Contributing
|
|
95
|
+
|
|
96
|
+
Issues and PRs welcome. The codebase holds itself to a high bar — see [CONTRIBUTING.md](./CONTRIBUTING.md).
|
package/build/src/cli.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* persnallyd CLI — the developer's window into the daemon.
|
|
4
|
+
* Merges into the `persnally` npm identity at Phase 1 launch.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync } from "node:child_process";
|
|
7
|
+
import { existsSync, rmSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { applyApiKey, configPath, loadConfig, saveConfig } from "./config.js";
|
|
11
|
+
import { CLIENTS, connectAll, connectClient } from "./connect.js";
|
|
12
|
+
import { runConsolidation } from "./consolidate.js";
|
|
13
|
+
import { chooseExtractor } from "./llm.js";
|
|
14
|
+
import { CATEGORIES, clearScope, loadScopes, setScope } from "./permissions.js";
|
|
15
|
+
import { alreadyImported, DENSITY_QUESTIONS, detectExports, eventsFromAnswers, isThin, markImported } from "./setup.js";
|
|
16
|
+
import { DEFAULT_PORT, startDaemon, VERSION } from "./daemon.js";
|
|
17
|
+
import { extractChatGPTEvents, parseChatGPTExport } from "./importers/chatgpt.js";
|
|
18
|
+
import { extractClaudeEvents, parseClaudeExport } from "./importers/claude.js";
|
|
19
|
+
import { DEFAULT_TRANSCRIPTS_DIR, extractClaudeCodeEvents, parseClaudeCodeTranscripts, } from "./importers/claude-code.js";
|
|
20
|
+
import { gitEvents, scanRepos } from "./importers/git.js";
|
|
21
|
+
import { autostartInstalled, installAutostart, LOG_FILE, removeAutostart, removePidFile, runningPid, startDetached, stopDaemon, writePidFile, } from "./lifecycle.js";
|
|
22
|
+
import { renderProfile, synthesizeProfile } from "./profile.js";
|
|
23
|
+
import { DEFAULT_DB_PATH, EventStore } from "./store.js";
|
|
24
|
+
const USAGE = `persnallyd ${VERSION} — so every AI finally knows you
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
persnallyd setup One command: find exports, import, synthesize, connect, open
|
|
28
|
+
persnallyd connect [client|--all] Add Persnally to claude-code | claude-desktop | cursor
|
|
29
|
+
persnallyd scope <client> <categories|--clear> Limit what a client can read (e.g. scope cursor technology,career)
|
|
30
|
+
persnallyd scope Show all client scopes
|
|
31
|
+
persnallyd init Create the local store (~/.persnally/persnally.db)
|
|
32
|
+
persnallyd import claude <dir> Import a Claude data export (needs ANTHROPIC_API_KEY)
|
|
33
|
+
persnallyd import claude-code [dir] Import Claude Code session transcripts (default ~/.claude/projects)
|
|
34
|
+
persnallyd import chatgpt <path> Import a ChatGPT export dir or conversations.json (needs ANTHROPIC_API_KEY)
|
|
35
|
+
persnallyd import git <path> [--author <email>] Import repo activity (offline, no LLM); path = repo or folder of repos
|
|
36
|
+
persnallyd profile Synthesize your profile from the store
|
|
37
|
+
persnallyd consolidate Reflect now: refresh decay, add behavior patterns, re-synthesize
|
|
38
|
+
persnallyd show [topics|events|profile] Show topics (default), recent events, or the profile
|
|
39
|
+
persnallyd forget <topic> Hard-delete a topic and everything derived from it
|
|
40
|
+
persnallyd forget --all Delete all data
|
|
41
|
+
persnallyd forget --batch <id> Undo one import batch
|
|
42
|
+
persnallyd status Store stats and daemon health
|
|
43
|
+
persnallyd start [--port N] Start the daemon in the background
|
|
44
|
+
persnallyd stop Stop the background daemon
|
|
45
|
+
persnallyd serve [--port N] Run the daemon in the foreground (127.0.0.1:${DEFAULT_PORT})
|
|
46
|
+
persnallyd autostart [--remove] Start the daemon at login and keep it alive (macOS)
|
|
47
|
+
persnallyd config set-key <key> Store the Anthropic API key (owner-only file) for the daemon
|
|
48
|
+
persnallyd config Show config (key masked)
|
|
49
|
+
`;
|
|
50
|
+
function parsePort(args) {
|
|
51
|
+
const i = args.indexOf("--port");
|
|
52
|
+
return i > -1 && args[i + 1] ? Number(args[i + 1]) : DEFAULT_PORT;
|
|
53
|
+
}
|
|
54
|
+
async function main() {
|
|
55
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
56
|
+
applyApiKey();
|
|
57
|
+
switch (cmd) {
|
|
58
|
+
case "setup": {
|
|
59
|
+
const port = parsePort(args);
|
|
60
|
+
console.log("Persnally setup — so every AI finally knows you.\n");
|
|
61
|
+
// 1. Extraction engine (optional — git-only works without one)
|
|
62
|
+
let engine = null;
|
|
63
|
+
try {
|
|
64
|
+
engine = await chooseExtractor("extract");
|
|
65
|
+
console.log(`✓ Extraction engine: ${engine.label}`);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
console.log("· No extraction engine (no API key, no Ollama) — conversation imports skipped, git still works.");
|
|
69
|
+
}
|
|
70
|
+
// 2. Daemon
|
|
71
|
+
if (!runningPid()) {
|
|
72
|
+
await startDetached(process.argv[1], port);
|
|
73
|
+
console.log(`✓ Daemon started (http://127.0.0.1:${port})`);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
console.log("✓ Daemon already running");
|
|
77
|
+
}
|
|
78
|
+
// 3. Conversation exports from ~/Downloads (zipped or unzipped)
|
|
79
|
+
const store = new EventStore();
|
|
80
|
+
let imported = 0;
|
|
81
|
+
for (const found of detectExports()) {
|
|
82
|
+
if (alreadyImported(found.origin)) {
|
|
83
|
+
console.log(`· Skipping ${found.origin} (already imported)`);
|
|
84
|
+
}
|
|
85
|
+
else if (engine) {
|
|
86
|
+
console.log(`→ Importing ${found.kind} export: ${found.origin}`);
|
|
87
|
+
const parsed = found.kind === "claude" ? parseClaudeExport(found.path) : parseChatGPTExport(found.path);
|
|
88
|
+
const result = found.kind === "claude"
|
|
89
|
+
? await extractClaudeEvents(parsed, engine.extract, engine.model)
|
|
90
|
+
: await extractChatGPTEvents(parsed, engine.extract, engine.model);
|
|
91
|
+
store.append(result.events);
|
|
92
|
+
markImported(found.origin);
|
|
93
|
+
imported += result.events.length;
|
|
94
|
+
console.log(` ✓ ${result.events.length} events`);
|
|
95
|
+
}
|
|
96
|
+
if (found.cleanup)
|
|
97
|
+
rmSync(found.cleanup, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
// 3b. Claude Code transcripts — local, no export wait. Capped at the 50 most
|
|
100
|
+
// recent sessions so setup stays fast; full history via `import claude-code`.
|
|
101
|
+
if (engine && existsSync(DEFAULT_TRANSCRIPTS_DIR) && !alreadyImported(DEFAULT_TRANSCRIPTS_DIR)) {
|
|
102
|
+
const { parsed, sessionsFound, sessionsDropped } = parseClaudeCodeTranscripts(DEFAULT_TRANSCRIPTS_DIR, 50);
|
|
103
|
+
if (parsed.conversations.length) {
|
|
104
|
+
console.log(`→ Importing Claude Code transcripts: ${parsed.conversations.length} session(s)` +
|
|
105
|
+
(sessionsDropped ? ` (most recent of ${sessionsFound} — full history: persnallyd import claude-code)` : ""));
|
|
106
|
+
const result = await extractClaudeCodeEvents(parsed, engine.extract, engine.model);
|
|
107
|
+
store.append(result.events);
|
|
108
|
+
markImported(DEFAULT_TRANSCRIPTS_DIR);
|
|
109
|
+
imported += result.events.length;
|
|
110
|
+
console.log(` ✓ ${result.events.length} events`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// 4. Git activity from ~/Projects
|
|
114
|
+
const projects = join(homedir(), "Projects");
|
|
115
|
+
if (existsSync(projects) && !alreadyImported(projects)) {
|
|
116
|
+
const summaries = scanRepos(projects);
|
|
117
|
+
if (summaries.length) {
|
|
118
|
+
const { events } = gitEvents(summaries);
|
|
119
|
+
store.append(events);
|
|
120
|
+
markImported(projects);
|
|
121
|
+
imported += events.length;
|
|
122
|
+
console.log(`✓ Imported ${summaries.length} git repo(s) from ~/Projects (${events.length} events, fully offline)`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
store.rebuild();
|
|
126
|
+
// 4b. Density floor — if everything is still thin, two questions beat an empty mirror
|
|
127
|
+
const signalCount = store.stats().byType["signal.topic"] ?? 0;
|
|
128
|
+
if (isThin(signalCount) && process.stdin.isTTY) {
|
|
129
|
+
console.log("\nYour history is light — two quick questions so Persnally starts with something real:");
|
|
130
|
+
const { createInterface } = await import("node:readline/promises");
|
|
131
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
132
|
+
const answers = [];
|
|
133
|
+
for (const q of DENSITY_QUESTIONS)
|
|
134
|
+
answers.push(await rl.question(` ${q}\n > `));
|
|
135
|
+
rl.close();
|
|
136
|
+
const seeds = await eventsFromAnswers(answers, engine);
|
|
137
|
+
if (seeds.length) {
|
|
138
|
+
store.append(seeds);
|
|
139
|
+
store.rebuild();
|
|
140
|
+
imported += seeds.length;
|
|
141
|
+
console.log(` ✓ Seeded ${seeds.length} signal(s) from your answers`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// 5. Profile
|
|
145
|
+
if (engine && store.stats().total > 0) {
|
|
146
|
+
console.log("→ Synthesizing your profile…");
|
|
147
|
+
const profileEngine = await chooseExtractor("profile");
|
|
148
|
+
await synthesizeProfile(store, profileEngine.extract, profileEngine.model);
|
|
149
|
+
console.log(" ✓ Profile ready");
|
|
150
|
+
}
|
|
151
|
+
store.close();
|
|
152
|
+
// 6. AI clients
|
|
153
|
+
for (const { client, file } of connectAll()) {
|
|
154
|
+
console.log(file ? `✓ Connected ${client}` : `· ${client} not installed — skipped`);
|
|
155
|
+
}
|
|
156
|
+
console.log(`\nDone${imported ? ` — ${imported} events imported` : ""}. Dashboard: http://127.0.0.1:${port}`);
|
|
157
|
+
if (process.platform === "darwin" && process.stdout.isTTY) {
|
|
158
|
+
try {
|
|
159
|
+
execFileSync("open", [`http://127.0.0.1:${port}`]);
|
|
160
|
+
}
|
|
161
|
+
catch { /* non-fatal */ }
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
case "scope": {
|
|
166
|
+
const [client, spec] = args;
|
|
167
|
+
if (!client) {
|
|
168
|
+
const scopes = loadScopes();
|
|
169
|
+
const entries = Object.entries(scopes);
|
|
170
|
+
if (!entries.length) {
|
|
171
|
+
console.log("No client scopes — every connected client sees everything.");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for (const [c, cats] of entries)
|
|
175
|
+
console.log(`${c}: ${cats.join(", ")}`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (!spec)
|
|
179
|
+
return die("usage: persnallyd scope <client> <cat1,cat2|--clear>");
|
|
180
|
+
if (spec === "--clear") {
|
|
181
|
+
console.log(clearScope(client) ? `Cleared scope for ${client} — it now sees everything.` : `${client} had no scope.`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const cats = spec.split(",").map((c) => c.trim()).filter(Boolean);
|
|
185
|
+
const invalid = cats.filter((c) => !CATEGORIES.includes(c));
|
|
186
|
+
if (invalid.length)
|
|
187
|
+
return die(`unknown categor${invalid.length > 1 ? "ies" : "y"}: ${invalid.join(", ")}\nvalid: ${CATEGORIES.join(", ")}`);
|
|
188
|
+
setScope(client, cats);
|
|
189
|
+
console.log(`${client} can now read only: ${cats.join(", ")}. Restart that client to apply.`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
case "connect": {
|
|
193
|
+
const target = args[0] === "--all" || !args[0] ? null : args[0];
|
|
194
|
+
if (target && !CLIENTS.includes(target))
|
|
195
|
+
return die(`unknown client — use ${CLIENTS.join(" | ")} | --all`);
|
|
196
|
+
const results = target ? [{ client: target, file: connectClient(target) }] : connectAll();
|
|
197
|
+
for (const { client, file } of results) {
|
|
198
|
+
console.log(file ? `Connected ${client} (${file})` : `${client} not installed — skipped`);
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
case "config": {
|
|
203
|
+
if (args[0] === "set-key") {
|
|
204
|
+
if (!args[1]?.startsWith("sk-ant-"))
|
|
205
|
+
return die("expected an Anthropic key (sk-ant-...)");
|
|
206
|
+
saveConfig({ anthropic_api_key: args[1] });
|
|
207
|
+
console.log(`Key saved to ${configPath()} (mode 600). Restart the daemon to apply: persnallyd stop`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const cfg = loadConfig();
|
|
211
|
+
const key = typeof cfg.anthropic_api_key === "string" ? cfg.anthropic_api_key : "";
|
|
212
|
+
console.log(`Config: ${configPath()}`);
|
|
213
|
+
console.log(`anthropic_api_key: ${key ? key.slice(0, 12) + "…" + key.slice(-4) : "(not set)"}`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
case "init": {
|
|
217
|
+
const store = new EventStore();
|
|
218
|
+
store.close();
|
|
219
|
+
console.log(`Initialized ${DEFAULT_DB_PATH}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
case "import": {
|
|
223
|
+
const [kind, path] = args;
|
|
224
|
+
const usage = "usage: persnallyd import claude|claude-code|chatgpt|git <path>";
|
|
225
|
+
if (!kind)
|
|
226
|
+
return die(usage);
|
|
227
|
+
let events, batch;
|
|
228
|
+
if (kind === "claude-code") {
|
|
229
|
+
const engine = await chooseExtractor("extract");
|
|
230
|
+
const root = path ?? DEFAULT_TRANSCRIPTS_DIR;
|
|
231
|
+
const { parsed, sessionsFound, sessionsDropped } = parseClaudeCodeTranscripts(root);
|
|
232
|
+
if (!parsed.conversations.length)
|
|
233
|
+
return die(`No usable sessions found at ${root}`);
|
|
234
|
+
console.error(`Found ${sessionsFound} session(s)${sessionsDropped ? ` — importing the ${parsed.conversations.length} most recent` : ""}. ` +
|
|
235
|
+
`Extracting with ${engine.label}...`);
|
|
236
|
+
({ events, batch } = await extractClaudeCodeEvents(parsed, engine.extract, engine.model, root));
|
|
237
|
+
}
|
|
238
|
+
else if (!path) {
|
|
239
|
+
return die(usage);
|
|
240
|
+
}
|
|
241
|
+
else if (kind === "git") {
|
|
242
|
+
const authorIdx = args.indexOf("--author");
|
|
243
|
+
const summaries = scanRepos(path, authorIdx > -1 ? args[authorIdx + 1] : undefined);
|
|
244
|
+
if (!summaries.length)
|
|
245
|
+
return die(`No git repos with your commits found at ${path}`);
|
|
246
|
+
console.error(`Found ${summaries.length} repo(s): ${summaries.map((s) => `${s.repo} (${s.commits} commits)`).join(", ")}`);
|
|
247
|
+
({ events, batch } = gitEvents(summaries));
|
|
248
|
+
}
|
|
249
|
+
else if (kind === "claude" || kind === "chatgpt") {
|
|
250
|
+
const engine = await chooseExtractor("extract");
|
|
251
|
+
const parsed = kind === "claude" ? parseClaudeExport(path) : parseChatGPTExport(path);
|
|
252
|
+
console.error(`Parsed ${parsed.conversations.length} conversations. Extracting with ${engine.label}...`);
|
|
253
|
+
({ events, batch } = kind === "claude"
|
|
254
|
+
? await extractClaudeEvents(parsed, engine.extract, engine.model)
|
|
255
|
+
: await extractChatGPTEvents(parsed, engine.extract, engine.model));
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
return die(`unknown import source "${kind}" — use claude, claude-code, chatgpt, or git`);
|
|
259
|
+
}
|
|
260
|
+
const store = new EventStore();
|
|
261
|
+
store.append(events);
|
|
262
|
+
store.rebuild();
|
|
263
|
+
store.close();
|
|
264
|
+
console.log(`Imported ${events.length} events (batch ${batch}).`);
|
|
265
|
+
console.log(`Undo with: persnallyd forget --batch ${batch}`);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
case "consolidate": {
|
|
269
|
+
const engine = await chooseExtractor("extract").catch(() => null);
|
|
270
|
+
const store = new EventStore();
|
|
271
|
+
const r = await runConsolidation(store, engine);
|
|
272
|
+
store.close();
|
|
273
|
+
console.log(`Consolidation: ${r.newSignals} new signal(s) since last run, ${r.assertions} behavior assertion(s) added, profile ${r.profileRefreshed ? "refreshed" : "unchanged"}.`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
case "profile": {
|
|
277
|
+
const engine = await chooseExtractor("profile");
|
|
278
|
+
const store = new EventStore();
|
|
279
|
+
console.error(`Synthesizing profile with ${engine.label}...`);
|
|
280
|
+
const profile = await synthesizeProfile(store, engine.extract, engine.model);
|
|
281
|
+
store.close();
|
|
282
|
+
console.log(renderProfile(profile));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
case "show": {
|
|
286
|
+
const store = new EventStore();
|
|
287
|
+
if (args[0] === "profile") {
|
|
288
|
+
const p = store.getProfile();
|
|
289
|
+
console.log(p ? renderProfile(p) : "No profile yet. Run: persnallyd profile");
|
|
290
|
+
}
|
|
291
|
+
else if (args[0] === "events") {
|
|
292
|
+
for (const e of store.query({ limit: 20 })) {
|
|
293
|
+
console.log(`${e.ts} ${e.type.padEnd(18)} ${e.source.padEnd(16)} ${summarize(e.payload)}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
const topics = store.topics(25);
|
|
298
|
+
if (!topics.length)
|
|
299
|
+
console.log("No topics yet. Run an import or connect an MCP client.");
|
|
300
|
+
for (const t of topics) {
|
|
301
|
+
console.log(`${t.weight.toFixed(2).padStart(6)} ${t.topic} (${t.category}, ${t.signals} signals)`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
store.close();
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
case "forget": {
|
|
308
|
+
const store = new EventStore();
|
|
309
|
+
if (args[0] === "--all") {
|
|
310
|
+
store.forgetAll();
|
|
311
|
+
console.log("All data deleted.");
|
|
312
|
+
}
|
|
313
|
+
else if (args[0] === "--batch" && args[1]) {
|
|
314
|
+
console.log(`Deleted ${store.forgetBatch(args[1])} events from batch ${args[1]}.`);
|
|
315
|
+
}
|
|
316
|
+
else if (args[0]) {
|
|
317
|
+
console.log(`Deleted ${store.forgetTopic(args[0])} events for "${args[0]}".`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
die("usage: persnallyd forget <topic> | --all | --batch <id>");
|
|
321
|
+
}
|
|
322
|
+
store.close();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
case "status": {
|
|
326
|
+
const store = new EventStore();
|
|
327
|
+
const s = store.stats();
|
|
328
|
+
store.close();
|
|
329
|
+
console.log(`Store: ${DEFAULT_DB_PATH}`);
|
|
330
|
+
console.log(`Events: ${s.total} (${s.first ?? "—"} → ${s.last ?? "—"})`);
|
|
331
|
+
for (const [t, n] of Object.entries(s.byType))
|
|
332
|
+
console.log(` ${t}: ${n}`);
|
|
333
|
+
const pid = runningPid();
|
|
334
|
+
console.log(pid ? `Daemon: running (pid ${pid})` : "Daemon: not running");
|
|
335
|
+
console.log(`Autostart: ${autostartInstalled() ? "installed" : "not installed"}`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
case "start": {
|
|
339
|
+
const existing = runningPid();
|
|
340
|
+
if (existing)
|
|
341
|
+
return die(`daemon already running (pid ${existing})`);
|
|
342
|
+
const pid = await startDetached(process.argv[1], parsePort(args));
|
|
343
|
+
console.log(`persnallyd started (pid ${pid}). Dashboard: http://127.0.0.1:${parsePort(args)}`);
|
|
344
|
+
console.log(`Logs: ${LOG_FILE}`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
case "stop": {
|
|
348
|
+
if (autostartInstalled()) {
|
|
349
|
+
console.error("Note: autostart is installed — launchd will restart the daemon. Use `persnallyd autostart --remove` to stop it permanently.");
|
|
350
|
+
}
|
|
351
|
+
const pid = await stopDaemon();
|
|
352
|
+
console.log(pid ? `Stopped daemon (pid ${pid}).` : "Daemon was not running.");
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
case "autostart": {
|
|
356
|
+
if (args[0] === "--remove") {
|
|
357
|
+
console.log(removeAutostart() ? "Autostart removed; daemon stopped." : "Autostart was not installed.");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// A running daemon holds the pidfile and would put launchd in a retry loop — hand over first.
|
|
361
|
+
const stopped = await stopDaemon();
|
|
362
|
+
if (stopped)
|
|
363
|
+
console.log(`Stopped existing daemon (pid ${stopped}) — launchd takes over.`);
|
|
364
|
+
const plist = installAutostart(process.argv[1], parsePort(args));
|
|
365
|
+
console.log(`Autostart installed (${plist}). The daemon now runs at login and restarts if it exits.`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
case "serve": {
|
|
369
|
+
const existing = runningPid();
|
|
370
|
+
if (existing)
|
|
371
|
+
return die(`daemon already running (pid ${existing}) — stop it first`);
|
|
372
|
+
const port = parsePort(args);
|
|
373
|
+
const store = new EventStore();
|
|
374
|
+
const server = startDaemon(store, port);
|
|
375
|
+
server.on("error", (e) => {
|
|
376
|
+
die(e.code === "EADDRINUSE" ? `port ${port} is already in use` : e.message);
|
|
377
|
+
});
|
|
378
|
+
writePidFile();
|
|
379
|
+
const shutdown = () => {
|
|
380
|
+
server.close();
|
|
381
|
+
store.close();
|
|
382
|
+
removePidFile();
|
|
383
|
+
process.exit(0);
|
|
384
|
+
};
|
|
385
|
+
process.on("SIGTERM", shutdown);
|
|
386
|
+
process.on("SIGINT", shutdown);
|
|
387
|
+
console.error(`persnallyd v${VERSION} listening on 127.0.0.1:${port}`);
|
|
388
|
+
console.error(`Dashboard: http://127.0.0.1:${port}`);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
default:
|
|
392
|
+
console.log(USAGE);
|
|
393
|
+
process.exitCode = cmd ? 1 : 0;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function summarize(payload) {
|
|
397
|
+
const s = JSON.stringify(payload);
|
|
398
|
+
return s.length > 80 ? s.slice(0, 77) + "..." : s;
|
|
399
|
+
}
|
|
400
|
+
function die(msg) {
|
|
401
|
+
console.error(msg);
|
|
402
|
+
process.exit(1);
|
|
403
|
+
}
|
|
404
|
+
main().catch((e) => die(e instanceof Error ? e.message : String(e)));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon config at ~/.persnally/config.json — most importantly the Anthropic
|
|
3
|
+
* key, so the launchd-run daemon (no shell env) can synthesize. Unknown fields
|
|
4
|
+
* are preserved (the file predates v2). Saved with owner-only permissions.
|
|
5
|
+
*/
|
|
6
|
+
export declare function loadConfig(): Record<string, unknown>;
|
|
7
|
+
export declare function saveConfig(updates: Record<string, unknown>): void;
|
|
8
|
+
/** Env wins over config; sets process.env so the Anthropic SDK picks it up. */
|
|
9
|
+
export declare function applyApiKey(): boolean;
|
|
10
|
+
export declare function configPath(): string;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon config at ~/.persnally/config.json — most importantly the Anthropic
|
|
3
|
+
* key, so the launchd-run daemon (no shell env) can synthesize. Unknown fields
|
|
4
|
+
* are preserved (the file predates v2). Saved with owner-only permissions.
|
|
5
|
+
*/
|
|
6
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { DATA_DIR } from "./paths.js";
|
|
9
|
+
// Resolved at call time so PERSNALLY_DIR overrides work in-process (tests), not just for subprocesses.
|
|
10
|
+
function configFile() {
|
|
11
|
+
return join(process.env.PERSNALLY_DIR ?? DATA_DIR, "config.json");
|
|
12
|
+
}
|
|
13
|
+
export function loadConfig() {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(configFile(), "utf-8"));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function saveConfig(updates) {
|
|
22
|
+
const file = configFile();
|
|
23
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
24
|
+
const merged = { ...loadConfig(), ...updates };
|
|
25
|
+
writeFileSync(file, JSON.stringify(merged, null, 2) + "\n");
|
|
26
|
+
chmodSync(file, 0o600);
|
|
27
|
+
}
|
|
28
|
+
/** Env wins over config; sets process.env so the Anthropic SDK picks it up. */
|
|
29
|
+
export function applyApiKey() {
|
|
30
|
+
if (process.env.ANTHROPIC_API_KEY)
|
|
31
|
+
return true;
|
|
32
|
+
const key = loadConfig().anthropic_api_key;
|
|
33
|
+
if (typeof key === "string" && key.startsWith("sk-ant-")) {
|
|
34
|
+
process.env.ANTHROPIC_API_KEY = key;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
export function configPath() {
|
|
40
|
+
const file = configFile();
|
|
41
|
+
return existsSync(file) ? file : `${file} (not created yet)`;
|
|
42
|
+
}
|