persnally 2.6.0 → 2.6.1

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.
@@ -5,7 +5,7 @@
5
5
  * and re-synthesizes the profile. Last-run state lives in config, not
6
6
  * the event log — it's operational state, not user data.
7
7
  */
8
- import type { ChosenExtractor } from "./llm.js";
8
+ import { type ChosenExtractor } from "./llm.js";
9
9
  import type { EventStore } from "./store.js";
10
10
  export declare const CONSOLIDATION_HOUR = 3;
11
11
  export interface ConsolidationResult {
@@ -8,6 +8,7 @@
8
8
  import { z } from "zod";
9
9
  import { loadConfig, saveConfig } from "./config.js";
10
10
  import { newEvent, PAYLOAD_SCHEMAS } from "./events.js";
11
+ import { chooseExtractor } from "./llm.js";
11
12
  import { synthesizeProfile } from "./profile.js";
12
13
  const ASSERTION_MIN_SIGNALS = 5;
13
14
  const PROFILE_MIN_SIGNALS = 10;
@@ -61,9 +62,12 @@ export async function runConsolidation(store, engine, now = new Date()) {
61
62
  store.rebuild(now.getTime());
62
63
  }
63
64
  }
65
+ // The profile is the centerpiece artifact — always synthesize it with the
66
+ // profile model, not the cheaper assertions engine passed in as `engine`.
64
67
  let profileRefreshed = false;
65
68
  if (engine && newSignals.length >= PROFILE_MIN_SIGNALS) {
66
- await synthesizeProfile(store, engine.extract, engine.model);
69
+ const profileEngine = await chooseExtractor("profile");
70
+ await synthesizeProfile(store, profileEngine.extract, profileEngine.model);
67
71
  profileRefreshed = true;
68
72
  }
69
73
  saveConfig({ last_consolidation: now.toISOString() });
@@ -4,12 +4,12 @@
4
4
  */
5
5
  import http from "node:http";
6
6
  import { readFileSync } from "node:fs";
7
- import { loadConfig } from "./config.js";
7
+ import { loadConfig, saveConfig } from "./config.js";
8
8
  import { runConsolidation, shouldRunNow } from "./consolidate.js";
9
9
  import { allowedCategories, loadScopes } from "./permissions.js";
10
10
  import { newEvent, validateEvent } from "./events.js";
11
11
  import { importNewClaudeCodeSessions } from "./importers/claude-code.js";
12
- import { chooseExtractor } from "./llm.js";
12
+ import { chooseExtractor, ollamaTags, pullOllamaModel, RECOMMENDED_LOCAL_MODEL } from "./llm.js";
13
13
  import { synthesizeProfile } from "./profile.js";
14
14
  export const DEFAULT_PORT = 4983;
15
15
  const MAX_BODY_BYTES = 25 * 1024 * 1024; // generous for import batches; bounds memory
@@ -17,6 +17,7 @@ const MAX_QUERY_LIMIT = 10_000; // ceiling for public ?limit= params
17
17
  // Single source of truth for the user-visible version: package.json.
18
18
  const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8"));
19
19
  export const VERSION = pkg.version;
20
+ let pull = { state: "idle", model: "", percent: 0, status: "", error: "" };
20
21
  export function startDaemon(store, port = DEFAULT_PORT) {
21
22
  const localHosts = [`127.0.0.1:${port}`, `localhost:${port}`];
22
23
  const server = http.createServer(async (req, res) => {
@@ -83,6 +84,52 @@ export function startDaemon(store, port = DEFAULT_PORT) {
83
84
  const engine = await chooseExtractor("extract").catch(() => null);
84
85
  return json(res, 200, await runConsolidation(store, engine));
85
86
  }
87
+ // Engine onboarding: status + live key-save + one-click local-model pull.
88
+ if (req.method === "GET" && url.pathname === "/engine") {
89
+ const tags = await ollamaTags();
90
+ const cfgKey = loadConfig().anthropic_api_key;
91
+ const key = process.env.ANTHROPIC_API_KEY || (typeof cfgKey === "string" ? cfgKey : "");
92
+ return json(res, 200, {
93
+ hasKey: key.startsWith("sk-ant-"),
94
+ keyMasked: key ? `${key.slice(0, 12)}…${key.slice(-4)}` : "",
95
+ hasProfile: !!store.getProfile(),
96
+ ollama: { reachable: tags !== null, models: tags ?? [], hasModel: (tags?.length ?? 0) > 0 },
97
+ recommended: RECOMMENDED_LOCAL_MODEL,
98
+ pull,
99
+ });
100
+ }
101
+ if (req.method === "POST" && url.pathname === "/engine/key") {
102
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
103
+ return json(res, 415, { error: "Content-Type must be application/json" });
104
+ }
105
+ const body = (await readBody(req));
106
+ const key = typeof body.key === "string" ? body.key.trim() : "";
107
+ if (!key.startsWith("sk-ant-"))
108
+ return json(res, 400, { error: "expected an Anthropic key (sk-ant-…)" });
109
+ saveConfig({ anthropic_api_key: key });
110
+ process.env.ANTHROPIC_API_KEY = key; // apply to the running daemon — no restart needed
111
+ return json(res, 200, { ok: true, keyMasked: `${key.slice(0, 12)}…${key.slice(-4)}` });
112
+ }
113
+ if (req.method === "POST" && url.pathname === "/engine/pull") {
114
+ if (!(req.headers["content-type"] ?? "").includes("application/json")) {
115
+ return json(res, 415, { error: "Content-Type must be application/json" });
116
+ }
117
+ if (pull.state === "pulling")
118
+ return json(res, 200, { started: false, ...pull });
119
+ const body = (await readBody(req).catch(() => ({})));
120
+ const model = typeof body.model === "string" && body.model ? body.model : RECOMMENDED_LOCAL_MODEL;
121
+ if ((await ollamaTags()) === null) {
122
+ return json(res, 400, { error: "Ollama isn't running. Install it from ollama.com, then try again." });
123
+ }
124
+ pull = { state: "pulling", model, percent: 0, status: "starting", error: "" };
125
+ pullOllamaModel(model, (p) => { pull.percent = p.percent; pull.status = p.status; })
126
+ .then(() => { pull = { ...pull, state: "done", percent: 100, status: "ready" }; })
127
+ .catch((e) => { pull = { ...pull, state: "error", error: e instanceof Error ? e.message : String(e) }; });
128
+ return json(res, 200, { started: true, model });
129
+ }
130
+ if (req.method === "GET" && url.pathname === "/engine/pull") {
131
+ return json(res, 200, pull);
132
+ }
86
133
  if (req.method === "GET" && url.pathname === "/events") {
87
134
  const ids = url.searchParams.get("ids");
88
135
  if (ids)
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>Persnally — your context engine</title>
7
+ <link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAQKADAAQAAAABAAAAQAAAAABGUUKwAAAMP0lEQVRoBa1aW4ydVRXe58yZmbbQFkpTiobS8KBCmxBihGr1oUpCMIIhIaZqTRSjiU8+aIIB4cWYGJI+iPEBJSExxgRtNFVpTCBE64MBk3pBIgSqpXKxldKW6dB25sz5Xd+3Lnvv/58zF/Sf031Z61vf+tb+9385B3p33Nv0eqlpUuql1MifHWGROYziIsCmapHWj8Arg/E4nTEEWAZiEkLpiVFAqYFmze2qCg0W2EsDiVGoEimzmrLFEMxHhLHDQCznbbxlRxd4G6jFrVUNyhkuTjNDUDnDILJWUjRMUo8jKsRZIEPa+GUZan7MagsMLUvUQOyAeTug5cI0jTODo52ma6kTtxgWkd7CI0dUxwHT5zNgalqgMWEhPRKHZdE04e3iu5Y2Q7EWlINKbJDSIKi7YW1LEdYlgiVzMcWK8RG3BINhOpwDpNK7EKoak5iOnMZwBCPo/yl9caG17jIjz0CxbUTUSoRmjI+sZ6fjrqVMbN6i+MBnFxZHF9VUweVu7cdvIfodnNd4rKWoHFkc5/0KLAUDohiZw92Se+K5hXJVjGpFOof1rTTkc0gOjzRwuXtVDB4k8VZ8thQa7AzA537v/9c1WzqxZSmkhIYswFVli4+8l7uQDOt3BPjc732nmKUTe5j3OTxzv1MG4/RwPMjyfvWE3qOSGLciTQqtGeMj7wtyCbCYmrPF0MU4l/WFJMH6FmqBiqqMcLVpCoZFE8PY4jRcUV7LUkiP8M6TeGWJnVlUdNa4a2klXlb6YvjIGNLVwi0EGTjyXrJ5Xgx4+S+IlsYbbEkp74SBvKWGMVtotYmXLB756sRhcU9eu7BISAgNfNfS2UIrCxubho5ummzxkfeoTcddi2WhI7ytbbLiJ7FL7hK5J0tZSWJggsvOUG1xr/fZaxZ2q38SU51SBHWs4mqlh5RMpcVwHsYotYvPZwC+VljXEpSepjAwusXgbut9tzgxWBzig8ICmLvHMXgB9Du4E7YColLKShKXeORtCXBL7ovCTCctnS3kRXjfKaZJI36EGuzE9fs6w3Q4SgsLmMqPHRN9tHGUidUIC63m0lltaZfqSZXBb6MdIhic1XpWLMZ1a9Ila7JX6jk9kxZGaW6Ypgbp2q1p66Y06Kczs+n4yfSfsyhjYmJ5ochSS+8KcAh7AcvPKgEKX9dSrsGF+bR3T7r7VoHbce58unt/euVU2n19+tJtacf2ND1prpNn0uFn04+fSkdfy8blhY5ZO0+oVUJmr5Hfhaxsen2fEVJZDCaABlIuXUsvG9kjF4fpk7vSA/vSpP3KYd4tl6W7PpJuuTF957H066frGgiJxYqMbYtLUjjaQHDkWygItJ4qjCeWAGkkXPZMecg52XlN+sbetvrAXHZp+tbnk8CeOFLU4CTWFxm7FhNl0iOykS3UF6PaxYwPO4W0LDYVXep2gWun0tfuwlWxxDE5ke7dm67alIYLSIGPpiObTjm0xQqLiWpwRIRRCLZpii1EUhVhCgsL2F2394rFNb2N6mWBDz2TTr2VrtiQ7vhg2nODAbST7fTZj6YHf5r6U2YPQpkvlhHp5U9/QWVMCFI4WvtGFlyw0asQhlXSA6muaB9+PH3vl4iVW6rcRp/8U7rnU2nfx8KPwW0fSI/8Js283bm3tjNSRWPSzWmCtDOAcPZlqJpswCIDJa7sJVJdla6U/vqP9PChNDWBLS67ZXoKZTx0ML30WgXcenm6flua51MC6TR1zkibpkRWjIBSi47VWOwoFtAmyiEkQKjyQA5ZK10pHfxDujCHdVW8eKWAs7PpV0+3gOl9V/MyUJ5FpVvylnQEoBRtLA8wq34SazGlLnmE/e1YGkyAVfOoVyx/Poq05cP43ZsrDNYjxwhWD10k44O/wBCRo/xdiAV6tPbIXVl0wdxovpRmL6Q3z3H53aW96JbH8MX5tMavWgnZuC7qIYo3FytD3JZSCdCifl8ZZtQog4rLLmJbCEJkrARALWYJrzrlzigfB7J3hvkhXo3KQ04LWPUPrwJBpjE6RStzedAaOvcFnhg+OTW2wHKIIGbiAMPFjym5cAd4uuEaKEBi0Wu6DJsb2gWofJYZURrJ1llcl/YKUpjADW8XMYRmOMlaFsGrhYNS07rptGUjbp3KqX4Zy7Vx5eX50ashp8+lEZ7kBR3C/MPqPHOBoToTCKGOx23U4boI4jGhTAig8jBJxtCrTb+X3v+efHOM8OEw3fTeAsfhyyeUDlnl3/ywuTjfDBc4pSXnUWWsTdwui4OYpsZe5GEODDNlS6YEBrDOceeH0qb1rIGpxC/Ktm5qbr+5gkrscy83EzzrC3Iemmbn9t7uHT15xZAymCdE2JQJrckALhKs8ioBQfXeFQMqdN0YcBoWnwGjx/Yr0zc/ne7/EZ6y/X4jm2fjJc39n+m96wpHsD92onn++GgwAcCGdenbXxjsuaEv3xbenGm++4vhT54aytcJT6s5RWRYOIChfLnQnxZLuRrCfIhwsdbXXkehv31XuvYqeRdqTpzB1v/Ezf3rri79GB/4/ejsbCN3VbmUv3rn4JYb7fxvWt97YN/k88cXjry0wBqwtgz29JDNP1hdFHsvmcgC7gsRvUtHVOCYRO70J06nbVvSjmvkU3yDpDeaF19tHvvdwtQkzvv6tenDO/1rKBFyHnbv7D/zwlDeRHhoDraFdHOZAHS4iLU0NWJWWGCU+FBfcHOIRvbD/p83cntZ4pCvl/c9On92dtTnvV1qGNXPB/DIw8SWhwooi6fCp1lKtvhtFDor6VBTSxeAWuAqDlmzvx9v7nlEtkdhLYZHX2++8tDckRdHusXlips53zx5ZFhA8Cr127/MD/BiwA/Ok425eirFvSoW6xrfB8TFUCVFrB82JA1taqi2yprJ5okjzeceHH3x1v6u63qyp2VLnJ9LctM89MeFA4cX3nhrJK+oyMt/U4Pm+wfn5Dr++E2DtdO9V98Y7T9w8bljssEyRkbMpIk1qenqsTgF2Jd69YPeR9azK23kq9RrVdOTzQuvNF//4WjzhrR5I57NM283/z7dzJ5v5HksH6cGmWwkKe++Ry/84PG+fJV7/dTo1MxoWq5HOF2C9ZqcE3qBIE7R41/mHKX6SEwu461q4Plu5KcUuaZku8ttUVCiUl6q5QunZ9T8tqLy+OtPpH+dlOeBPBkS1Id0U2jSLV5nhXSF2xkQEHHWGThsfsrcXamnMbI3VEYCK9UXzKbBjUrkocabkfpizRSDVjJ7MjfSDBcfCCicExhsgF4OWyoxV++MilNIbgXFeI9SjymqpFsaF1tGMSkiYbRFcx3eO15RPXkSI8C9OlYpJFDpSOY4McfE4AgXm5qZ29Ew4ihasxRGjSDOkKZdo4h0jKe2XoDVFvI6FO6t8Wgnrc0tY04MuyeQUeBpRmOBjtE+2DiopKslIhXZjrIt5KkBGpeAPjgHeHjkQ244PbmxmUCPtjo1yPax+7RXEisr7ozkdWBB4roiNTGyhZAXL3PI4XFlAhjhskSNfKV69p+jnx2WLyZWgzyJ5Q0Hj1izmDLSqfTAlsww4gKl04MZi0a9GlhGwcVEBIjsHV9eGCNdKbJ06gX3cNjwpxFNhlZu83ISCJAGCeSvoDUjXDjQ+orY1FwqmBhCTSsMGtthxsND+TLIoeaKrMQJTH4rl1diUpYJwsDUSiuxOgsSoEKmIen0sQAypDBSujkJUUpuIZ1HqxXFVIDg8X86MA2LJ0CoRVmghrt0jQpMJmGOPK2jCiriZC4HzgAHbJeSrjCBF3iNhaE2ZgtcEoGrDGkUxoFj/Bx5X1LxXOE6CaNHgZaXkDwHqmsUYBzlquuFWBmN0RN48hylJMhB5RlAGk0vrYmDLHPUzK5Xy48yDA4rthDDjQPxEeUJLLyMd1NIiShVIP78EAxxzlyqtLFiHJBFFHosV5FaglpbSCxaibUOzkat17cE8Z7VEsCmJo/SvmCGvhzlSS2ZdgpwlzEakYkUIP7XY+XNdBw5lxDw0IwaH9BSRBklEY4MLFhKZQogktzFPbctnZEFnlRWP/4bGWnFb2e8Kz0TKAtbNMVUxtlCVzE1RRUzYz312NrgKLNUzEIrZdtFnEWWsihCUwkAimnhmNMlE7is6AsprgucOJZltkXIeATh9WGAc1dVGYJV7AoTtEQo4+JZCfWmkq4ZIUjctVabOqMX3KT/Akhx01MRJLxPAAAAAElFTkSuQmCC" />
7
8
  <style>
8
9
  :root {
9
10
  --bg: #0B0C0E; /* dark, default */
@@ -239,6 +240,17 @@
239
240
 
240
241
  <div id="delta"></div>
241
242
 
243
+ <style>
244
+ .onboard{margin:0 0 22px}
245
+ .onboard .ob-card{border:1px solid #2c67ff55;box-shadow:0 0 0 1px #2c67ff14}
246
+ .onboard .ob-or{margin:16px 0 8px;color:#8b90a0;font-size:13px}
247
+ .onboard .ob-row{display:flex;gap:8px;max-width:540px}
248
+ .onboard .ob-bar{height:8px;border-radius:6px;background:#ffffff14;overflow:hidden;margin-top:10px}
249
+ .onboard .ob-fill{height:100%;width:0;background:#2c67ff;transition:width .35s ease}
250
+ .onboard input{flex:1;background:#0e1016;border:1px solid #1b1e26;border-radius:8px;color:#f5f6f8;padding:9px 12px;font:inherit;min-width:0}
251
+ </style>
252
+ <div id="onboard" class="onboard" style="display:none"></div>
253
+
242
254
  <div class="hero reveal" style="animation-delay:.05s">
243
255
  <div class="scale-beat" id="scaleBeat"></div>
244
256
  <div class="archetype" id="archetype">Reading your context…</div>
@@ -714,6 +726,62 @@ function switchView(v) {
714
726
  $("vGraph").onclick = () => switchView("map");
715
727
  $("vList").onclick = () => switchView("list");
716
728
 
729
+ /* ── engine onboarding (local-first: Ollama one-click, key optional) ── */
730
+ function renderOnboard(engine, profile) {
731
+ const el = $("onboard");
732
+ // Show only when there's no engine at all and no portrait yet — once either
733
+ // exists the normal dashboard (and the Re-synthesize button) take over.
734
+ const ready = engine && (engine.hasKey || (engine.ollama && engine.ollama.hasModel));
735
+ if (DEMO || !engine || ready || profile) { el.style.display = "none"; el.innerHTML = ""; return; }
736
+ el.style.display = "";
737
+ const oll = engine.ollama || { reachable:false };
738
+ const local = oll.reachable
739
+ ? `<button class="btn" id="obPull">Set up local AI — download model (~2GB)</button>
740
+ <div class="sub" style="margin-top:6px">Runs entirely on your machine. Free. Nothing leaves your computer.</div>
741
+ <div id="obProg" style="display:none"><div class="ob-bar"><div class="ob-fill" id="obFill"></div></div><div class="sub" id="obStat" style="margin-top:6px"></div></div>`
742
+ : `<a class="btn" href="https://ollama.com/download" target="_blank" rel="noopener">Install Ollama — local &amp; free <span class="arr">↗</span></a>
743
+ <div class="sub" style="margin-top:6px">One install and your data stays on this machine. <button class="link-btn" id="obRecheck">Recheck</button> once it's running.</div>`;
744
+ el.innerHTML = `<div class="card ob-card">
745
+ <div class="claim-title">Set up your AI engine to see your portrait</div>
746
+ <div class="sub" style="margin:4px 0 14px">Persnally needs a model to read your history and write your self-portrait. Choose local (private, free) — or bring your own key.</div>
747
+ <div style="display:flex;flex-direction:column;gap:2px;max-width:540px">${local}</div>
748
+ <div class="ob-or">— or use your own Anthropic key —</div>
749
+ <div class="ob-row"><input id="obKey" type="password" placeholder="sk-ant-…" autocomplete="off" spellcheck="false"><button class="btn ghost" id="obSaveKey">Save &amp; use</button></div>
750
+ <div class="sub" id="obKeyMsg" style="margin-top:6px"></div>
751
+ <div class="sub" style="margin-top:14px">No model yet? You can still import code offline — <code>persnallyd import git &lt;path&gt;</code> (no AI needed).</div>
752
+ </div>`;
753
+ if (oll.reachable) $("obPull").onclick = () => startPull(engine.recommended);
754
+ else $("obRecheck").onclick = () => loadAll();
755
+ $("obSaveKey").onclick = saveKey;
756
+ $("obKey").addEventListener("keydown", (e) => { if (e.key === "Enter") saveKey(); });
757
+ }
758
+
759
+ async function startPull(model) {
760
+ $("obProg").style.display = ""; $("obPull").disabled = true;
761
+ $("obStat").textContent = "Starting…";
762
+ const r = await fetch("/engine/pull", { method:"POST", headers:{ "content-type":"application/json" }, body: JSON.stringify({ model }) });
763
+ if (!r.ok) { const e = await r.json().catch(()=>({})); $("obStat").textContent = e.error || ("Couldn't start (" + r.status + ")"); $("obPull").disabled=false; return; }
764
+ (async function poll() {
765
+ const p = await get("/engine/pull");
766
+ if (!p) return void setTimeout(poll, 1500);
767
+ $("obFill").style.width = (p.percent||0) + "%";
768
+ if (p.state === "pulling") { $("obStat").textContent = (p.status||"downloading") + " · " + (p.percent||0) + "%"; return void setTimeout(poll, 1500); }
769
+ if (p.state === "error") { $("obStat").textContent = "Failed: " + (p.error||"unknown"); $("obPull").disabled=false; return; }
770
+ if (p.state === "done") { $("obStat").textContent = "Model ready — writing your portrait…"; await fetch("/synthesize", { method:"POST" }); return void loadAll(); }
771
+ setTimeout(poll, 1500);
772
+ })();
773
+ }
774
+
775
+ async function saveKey() {
776
+ const key = $("obKey").value.trim();
777
+ if (!key.startsWith("sk-ant-")) { $("obKeyMsg").textContent = "Enter a key starting with sk-ant-"; return; }
778
+ $("obSaveKey").disabled = true; $("obKeyMsg").textContent = "Saving…";
779
+ const r = await fetch("/engine/key", { method:"POST", headers:{ "content-type":"application/json" }, body: JSON.stringify({ key }) });
780
+ if (!r.ok) { const e = await r.json().catch(()=>({})); $("obKeyMsg").textContent = e.error || ("Failed (" + r.status + ")"); $("obSaveKey").disabled=false; return; }
781
+ $("obKeyMsg").textContent = "Saved — writing your portrait…";
782
+ await fetch("/synthesize", { method:"POST" }); loadAll();
783
+ }
784
+
717
785
  /* ── load ── */
718
786
  async function loadAll() {
719
787
  let stats, profile, topics, reads, assertions, voice, activity;
@@ -728,6 +796,7 @@ async function loadAll() {
728
796
  ({ stats, profile, topics, reads, assertions, voice, activity } = DEMO_DATA());
729
797
  }
730
798
  topics = topics||[]; reads = reads||[]; assertions = assertions||[];
799
+ const engine = DEMO ? null : await get("/engine");
731
800
  const st = $("status");
732
801
  st.querySelector(".dot").classList.toggle("off", DEMO);
733
802
  st.querySelector(".lbl").textContent = DEMO ? "preview" : "active";
@@ -736,6 +805,7 @@ async function loadAll() {
736
805
  renderDelta(topics, reads, assertions);
737
806
  renderScaleBeat(stats);
738
807
  renderPortrait(profile);
808
+ renderOnboard(engine, profile);
739
809
  renderVoice(voice);
740
810
  renderTopicList(topics);
741
811
  renderMap(topics);
@@ -749,7 +819,8 @@ async function loadAll() {
749
819
  $("synthesize").onclick = async () => {
750
820
  const b = $("synthesize"); if (DEMO) return alert("Preview mode — start persnallyd to synthesize for real.");
751
821
  b.disabled = true; b.textContent = "Synthesizing…";
752
- const r = await fetch("/synthesize", { method:"POST" }); if (!r.ok) alert("Synthesis failed — check the daemon log.");
822
+ const r = await fetch("/synthesize", { method:"POST" });
823
+ if (!r.ok) { const e = await r.json().catch(()=>({})); alert(e.error || "Synthesis failed — check the daemon log."); }
753
824
  b.disabled = false; b.textContent = "Re-synthesize"; loadAll();
754
825
  };
755
826
  $("reflect").onclick = async () => {
@@ -917,7 +988,7 @@ function DEMO_DATA() {
917
988
  const now = Date.now(), iso = (d)=>new Date(now-d).toISOString();
918
989
  return {
919
990
  stats: { total:4127, byType:{ "signal.topic":312, "signal.assertion":14, "context.read":1247 }, bySource:{ "git:persnallyd":58 }, first:iso(86400000*214), last:iso(3600000*2) },
920
- profile: { headline:"A refactor-first systems thinker who ships hardest under a deadline.", model:"fable-5", generated_at:iso(3600000*5).slice(0,10),
991
+ profile: { headline:"A refactor-first systems thinker who ships hardest under a deadline.", model:"opus-4-8", generated_at:iso(3600000*5).slice(0,10),
921
992
  sections:[
922
993
  { title:"How you work", body:"You build in tight, evidence-driven loops — prototype, measure, delete. You reach for SQLite and local-first architectures before reaching for a cloud, and you distrust abstractions you can't audit. Your commits are small and frequent, and you keep a working build at every step rather than big-bang merges.", evidence_event_ids:["e1","e2","e3","e4"] },
923
994
  { title:"How you decide", body:"You kill ideas fast and out loud. You actively seek the case against your own plans, and you change course when the evidence turns — rare for someone who generates this many ideas. You ask for the falsification first.", evidence_event_ids:["e5","e6","e7"] },
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Structured extraction shared by importers and profile synthesis.
3
- * Uses output_config structured outputs (works on every current model,
4
- * including Fable 5 where forced tool_choice is not supported).
3
+ * Uses output_config structured outputs (broadly supported; no forced tool_choice).
5
4
  */
6
5
  import { z } from "zod";
7
6
  export declare const DEFAULT_EXTRACT_MODEL: string;
@@ -15,6 +14,14 @@ export type LlmExtract = (opts: {
15
14
  }) => Promise<unknown>;
16
15
  /** Default extractor backed by the Anthropic API; injectable for tests. */
17
16
  export declare const anthropicExtract: LlmExtract;
17
+ export declare const RECOMMENDED_LOCAL_MODEL = "llama3.2";
18
+ /** Installed Ollama models, or null if Ollama isn't reachable (not installed / not running). */
19
+ export declare function ollamaTags(): Promise<string[] | null>;
20
+ /** Streams an `ollama pull`, reporting download progress (0–100). Throws on failure. */
21
+ export declare function pullOllamaModel(model: string, onProgress: (p: {
22
+ status: string;
23
+ percent: number;
24
+ }) => void): Promise<void>;
18
25
  export declare const ollamaExtract: LlmExtract;
19
26
  export interface ChosenExtractor {
20
27
  extract: LlmExtract;
package/build/src/llm.js CHANGED
@@ -1,13 +1,12 @@
1
1
  /**
2
2
  * Structured extraction shared by importers and profile synthesis.
3
- * Uses output_config structured outputs (works on every current model,
4
- * including Fable 5 where forced tool_choice is not supported).
3
+ * Uses output_config structured outputs (broadly supported; no forced tool_choice).
5
4
  */
6
5
  import Anthropic from "@anthropic-ai/sdk";
7
6
  import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
8
7
  import { z } from "zod";
9
8
  export const DEFAULT_EXTRACT_MODEL = process.env.PERSNALLY_MODEL ?? "claude-haiku-4-5";
10
- export const DEFAULT_PROFILE_MODEL = process.env.PERSNALLY_PROFILE_MODEL ?? "claude-fable-5";
9
+ export const DEFAULT_PROFILE_MODEL = process.env.PERSNALLY_PROFILE_MODEL ?? "claude-opus-4-8";
11
10
  /** Default extractor backed by the Anthropic API; injectable for tests. */
12
11
  export const anthropicExtract = async ({ model, instruction, schema, content, maxTokens }) => {
13
12
  const client = new Anthropic();
@@ -29,6 +28,56 @@ export const anthropicExtract = async ({ model, instruction, schema, content, ma
29
28
  // ── Local extraction via Ollama: zero key, zero cloud ───────
30
29
  const OLLAMA_URL = process.env.PERSNALLY_OLLAMA_URL ?? "http://127.0.0.1:11434";
31
30
  const LOCAL_MODEL_PREFERENCE = ["qwen2.5:14b", "qwen2.5:7b", "llama3.1:8b", "llama3.2", "qwen2.5:1.5b"];
31
+ // Smallest model that still yields a usable profile — the one-click local default.
32
+ // (~2GB; bigger models in LOCAL_MODEL_PREFERENCE win automatically once present.)
33
+ export const RECOMMENDED_LOCAL_MODEL = "llama3.2";
34
+ /** Installed Ollama models, or null if Ollama isn't reachable (not installed / not running). */
35
+ export async function ollamaTags() {
36
+ try {
37
+ const r = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(1500) });
38
+ if (!r.ok)
39
+ return null;
40
+ return (await r.json()).models?.map((m) => m.name) ?? [];
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ /** Streams an `ollama pull`, reporting download progress (0–100). Throws on failure. */
47
+ export async function pullOllamaModel(model, onProgress) {
48
+ const r = await fetch(`${OLLAMA_URL}/api/pull`, {
49
+ method: "POST",
50
+ body: JSON.stringify({ name: model, stream: true }),
51
+ });
52
+ if (!r.ok || !r.body)
53
+ throw new Error(`ollama pull: ${r.status} ${(await r.text().catch(() => "")).slice(0, 200)}`);
54
+ const reader = r.body.getReader();
55
+ const decoder = new TextDecoder();
56
+ let buf = "";
57
+ for (;;) {
58
+ const { done, value } = await reader.read();
59
+ if (done)
60
+ break;
61
+ buf += decoder.decode(value, { stream: true });
62
+ const lines = buf.split("\n");
63
+ buf = lines.pop() ?? ""; // keep the partial last line for the next chunk
64
+ for (const line of lines) {
65
+ if (!line.trim())
66
+ continue;
67
+ let obj;
68
+ try {
69
+ obj = JSON.parse(line);
70
+ }
71
+ catch {
72
+ continue;
73
+ }
74
+ if (obj.error)
75
+ throw new Error(`ollama pull: ${obj.error}`);
76
+ const percent = obj.total ? Math.round(((obj.completed ?? 0) / obj.total) * 100) : 0;
77
+ onProgress({ status: obj.status ?? "", percent });
78
+ }
79
+ }
80
+ }
32
81
  export const ollamaExtract = async ({ model, instruction, schema, content }) => {
33
82
  const r = await fetch(`${OLLAMA_URL}/api/chat`, {
34
83
  method: "POST",
@@ -50,14 +99,10 @@ export const ollamaExtract = async ({ model, instruction, schema, content }) =>
50
99
  async function localModel() {
51
100
  if (process.env.PERSNALLY_LOCAL_MODEL)
52
101
  return process.env.PERSNALLY_LOCAL_MODEL;
53
- try {
54
- const r = await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(1500) });
55
- const tags = (await r.json()).models?.map((m) => m.name) ?? [];
56
- return LOCAL_MODEL_PREFERENCE.find((p) => tags.some((t) => t === p || t === `${p}:latest`)) ?? tags[0] ?? null;
57
- }
58
- catch {
102
+ const tags = await ollamaTags();
103
+ if (!tags)
59
104
  return null;
60
- }
105
+ return LOCAL_MODEL_PREFERENCE.find((p) => tags.some((t) => t === p || t === `${p}:latest`)) ?? tags[0] ?? null;
61
106
  }
62
107
  /** Anthropic key wins (quality); otherwise local Ollama (privacy, zero setup); otherwise guide the user. */
63
108
  export async function chooseExtractor(purpose = "extract") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persnally",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "license": "FSL-1.1-MIT",
5
5
  "description": "Your own context engine — local-first, across every AI. So every AI finally knows you.",
6
6
  "type": "module",