clementine-agent 1.2.0 → 1.2.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.
@@ -4470,6 +4470,52 @@ If the tool returns nothing or errors, return an empty array \`[]\`.`,
4470
4470
  res.status(500).json({ error: String(err) });
4471
4471
  }
4472
4472
  });
4473
+ // Seed the user model from existing memory — one-shot Haiku pass over
4474
+ // MEMORY.md, top-salience chunks, and recent session summaries. Returns
4475
+ // proposed values for each slot; the dashboard UI shows them in an
4476
+ // editable review panel before applying. Nothing is written to
4477
+ // user_model_blocks until the user clicks "Apply" on a slot.
4478
+ app.post('/api/user-model/seed', async (_req, res) => {
4479
+ try {
4480
+ const gateway = await getGateway();
4481
+ const store = gateway.assistant?.memoryStore;
4482
+ if (!store) {
4483
+ res.status(503).json({ error: 'Memory store not available' });
4484
+ return;
4485
+ }
4486
+ // Build the LLM caller using the same Claude Agent SDK pattern as
4487
+ // periodic memory consolidation (see src/index.ts:684).
4488
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
4489
+ const llmCall = async (prompt) => {
4490
+ try {
4491
+ let result = '';
4492
+ const stream = query({
4493
+ prompt,
4494
+ options: {
4495
+ model: 'claude-haiku-4-5-20251001',
4496
+ maxTurns: 1,
4497
+ systemPrompt: 'You are a memory consolidation assistant. Extract only facts directly evidenced by the corpus. Be terse. Output exactly the requested format.',
4498
+ },
4499
+ });
4500
+ for await (const msg of stream) {
4501
+ if (msg.type === 'result') {
4502
+ result = msg.result ?? '';
4503
+ }
4504
+ }
4505
+ return result;
4506
+ }
4507
+ catch {
4508
+ return '';
4509
+ }
4510
+ };
4511
+ const { seedUserModelFromMemory } = await import('../memory/seed-user-model.js');
4512
+ const proposals = await seedUserModelFromMemory(store, llmCall);
4513
+ res.json({ ok: true, proposals });
4514
+ }
4515
+ catch (err) {
4516
+ res.status(500).json({ error: String(err) });
4517
+ }
4518
+ });
4473
4519
  // ── Memory chunk CRUD (dashboard curation) ───────────────────────
4474
4520
  // Lets the user fix wrong/stale memory directly from the search panel
4475
4521
  // instead of having to wait for auto-extraction to drift in the right
@@ -10801,13 +10847,15 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10801
10847
  <div style="color:var(--muted,#888);margin-bottom:12px;font-size:13px">
10802
10848
  What the agent always knows about you. These slots load into every conversation's context (above retrieved memory). Edit directly to correct or steer.
10803
10849
  </div>
10804
- <div style="display:flex;gap:8px;margin-bottom:12px;align-items:center">
10850
+ <div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap">
10805
10851
  <label style="font-size:13px;color:var(--text-secondary)">Scope:</label>
10806
10852
  <select id="user-model-scope" style="padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-input);color:var(--text);font-size:13px" onchange="loadUserModel()">
10807
10853
  <option value="">Global</option>
10808
10854
  </select>
10809
10855
  <button class="btn" onclick="loadUserModel()" style="font-size:13px">Refresh</button>
10856
+ <button class="btn-primary" onclick="seedUserModel()" style="font-size:13px;margin-left:auto" title="One-shot Haiku pass over MEMORY.md + top-salience chunks + recent sessions to propose initial slot values. Review and apply per-slot.">✨ Seed from existing memory</button>
10810
10857
  </div>
10858
+ <div id="user-model-seed-panel" style="display:none;margin-bottom:14px"></div>
10811
10859
  <div id="user-model-panel"><div class="empty-state">Loading user model…</div></div>
10812
10860
  </div>
10813
10861
 
package/dist/cli/index.js CHANGED
@@ -1333,7 +1333,7 @@ async function cmdConfigHardenPermissions(opts) {
1333
1333
  // ── Config keychain-fix-acl ─────────────────────────────────────────
1334
1334
  async function cmdConfigKeychainFixAcl(opts) {
1335
1335
  const { listClementineKeychainEntries, fixAllClementineEntries } = await import('../config/keychain-fix-acl.js');
1336
- const { markKeychainWizardDone } = await import('../config/keychain-first-run-wizard.js');
1336
+ const { markKeychainWizardDone, readPasswordFromTty } = await import('../config/keychain-first-run-wizard.js');
1337
1337
  const DIM = '\x1b[0;90m';
1338
1338
  const BOLD = '\x1b[1m';
1339
1339
  const GREEN = '\x1b[0;32m';
@@ -1358,35 +1358,54 @@ async function cmdConfigKeychainFixAcl(opts) {
1358
1358
  console.log();
1359
1359
  return;
1360
1360
  }
1361
+ console.log(` ${DIM}Enter your macOS login password ONCE — it authorizes${RESET}`);
1362
+ console.log(` ${DIM}all ${entries.length} ACL update${entries.length === 1 ? '' : 's'} in a single pass. Not stored.${RESET}`);
1363
+ console.log();
1364
+ const password = await readPasswordFromTty(` ${BOLD}macOS login password:${RESET} `);
1365
+ if (!password) {
1366
+ console.log();
1367
+ console.log(` ${YELLOW}Empty password — aborted.${RESET}`);
1368
+ console.log();
1369
+ return;
1370
+ }
1371
+ console.log();
1361
1372
  console.log(` ${BOLD}Fixing ACLs...${RESET}`);
1362
- console.log(` ${DIM}macOS may ask for your login keychain password (the system prompt — it DOES appear).${RESET}`);
1363
- console.log(` ${DIM}You may also be asked to "Always Allow" — pick that.${RESET}`);
1364
1373
  console.log();
1365
- const results = fixAllClementineEntries();
1374
+ const results = fixAllClementineEntries({ keychainPassword: password });
1366
1375
  let okCount = 0;
1367
1376
  let failCount = 0;
1377
+ let wrongPasswordHit = false;
1368
1378
  for (const r of results) {
1369
1379
  if (r.status === 'fixed') {
1370
1380
  console.log(` ${GREEN}✓${RESET} ${r.account}`);
1371
1381
  okCount++;
1372
1382
  }
1373
- else {
1383
+ else if (r.status === 'failed') {
1374
1384
  console.log(` ${RED}✗${RESET} ${r.account} ${DIM}— ${r.error}${RESET}`);
1375
1385
  failCount++;
1386
+ if (r.error && /MAC verification|AuthFailure|UserCanceled|-25293/i.test(r.error)) {
1387
+ wrongPasswordHit = true;
1388
+ }
1376
1389
  }
1377
1390
  }
1378
1391
  console.log();
1379
1392
  if (failCount === 0) {
1380
1393
  console.log(` ${GREEN}All ${okCount} entries fixed.${RESET} ${DIM}Future reads via the security CLI succeed silently.${RESET}`);
1381
1394
  }
1395
+ else if (wrongPasswordHit && okCount === 0) {
1396
+ console.log(` ${RED}Wrong password — no entries repaired.${RESET}`);
1397
+ console.log(` ${DIM}Re-run: clementine config keychain-fix-acl${RESET}`);
1398
+ }
1382
1399
  else {
1383
1400
  console.log(` ${YELLOW}${okCount} fixed, ${failCount} failed.${RESET}`);
1384
1401
  console.log(` ${DIM}Failed entries can be fixed manually in Keychain Access.app:${RESET}`);
1385
1402
  console.log(` ${DIM} search "clementine-agent" → double-click → Access Control → Allow all applications.${RESET}`);
1386
1403
  }
1387
- // Always mark the launch wizard satisfied user has explicitly decided
1388
- // to deal with this via the manual command.
1389
- markKeychainWizardDone(BASE_DIR);
1404
+ // Mark the launch wizard satisfied unless the password was clearly wrong —
1405
+ // user has explicitly decided to deal with this via the manual command.
1406
+ if (!(wrongPasswordHit && okCount === 0)) {
1407
+ markKeychainWizardDone(BASE_DIR);
1408
+ }
1390
1409
  console.log();
1391
1410
  }
1392
1411
  // ── Analytics ────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * One-time interactive wizard that repairs ACLs on legacy clementine-agent
3
- * keychain entries during `clementine launch`.
3
+ * keychain entries during `clementine launch` / `clementine update`.
4
4
  *
5
5
  * Why this exists: entries written before commit 88cfd99 used
6
6
  * `add-generic-password -T ''` (no apps pre-approved), so every Clementine
@@ -9,10 +9,11 @@
9
9
  * entries that need a one-time partition-list repair to stop the prompt
10
10
  * cascade.
11
11
  *
12
- * The manual fix is `clementine config keychain-fix-acl`. This wizard runs
13
- * the same fix automatically on the next `clementine launch` (where we
14
- * know we have a TTY for the macOS login-keychain password prompt), then
15
- * writes a sentinel so we never prompt again on this machine.
12
+ * macOS's `set-generic-password-partition-list` requires the login keychain
13
+ * password to authorize the ACL change once per entry. To avoid prompting
14
+ * the user N times for N entries, we ask once via masked stdin, then pass
15
+ * the password to each call via -k. End result: one password entry, all
16
+ * entries fixed in one pass.
16
17
  *
17
18
  * Skipped when:
18
19
  * - non-darwin platform (no keychain),
@@ -22,5 +23,15 @@
22
23
  */
23
24
  /** Write the sentinel so the wizard skips on subsequent launches. */
24
25
  export declare function markKeychainWizardDone(baseDir: string): void;
26
+ /**
27
+ * Read a line from the TTY without echoing characters. Each keypress shows
28
+ * an asterisk so the user has visual feedback for length. Returns the
29
+ * collected string. Ctrl-C aborts the process.
30
+ *
31
+ * Uses raw mode directly because Node's readline always echoes input.
32
+ * Exported so the manual `clementine config keychain-fix-acl` command can
33
+ * use the same one-prompt UX.
34
+ */
35
+ export declare function readPasswordFromTty(prompt: string): Promise<string>;
25
36
  export declare function runFirstRunKeychainWizardIfNeeded(baseDir: string): Promise<void>;
26
37
  //# sourceMappingURL=keychain-first-run-wizard.d.ts.map
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * One-time interactive wizard that repairs ACLs on legacy clementine-agent
3
- * keychain entries during `clementine launch`.
3
+ * keychain entries during `clementine launch` / `clementine update`.
4
4
  *
5
5
  * Why this exists: entries written before commit 88cfd99 used
6
6
  * `add-generic-password -T ''` (no apps pre-approved), so every Clementine
@@ -9,10 +9,11 @@
9
9
  * entries that need a one-time partition-list repair to stop the prompt
10
10
  * cascade.
11
11
  *
12
- * The manual fix is `clementine config keychain-fix-acl`. This wizard runs
13
- * the same fix automatically on the next `clementine launch` (where we
14
- * know we have a TTY for the macOS login-keychain password prompt), then
15
- * writes a sentinel so we never prompt again on this machine.
12
+ * macOS's `set-generic-password-partition-list` requires the login keychain
13
+ * password to authorize the ACL change once per entry. To avoid prompting
14
+ * the user N times for N entries, we ask once via masked stdin, then pass
15
+ * the password to each call via -k. End result: one password entry, all
16
+ * entries fixed in one pass.
16
17
  *
17
18
  * Skipped when:
18
19
  * - non-darwin platform (no keychain),
@@ -38,6 +39,64 @@ export function markKeychainWizardDone(baseDir) {
38
39
  // next launch — annoying but not broken.
39
40
  }
40
41
  }
42
+ /**
43
+ * Read a line from the TTY without echoing characters. Each keypress shows
44
+ * an asterisk so the user has visual feedback for length. Returns the
45
+ * collected string. Ctrl-C aborts the process.
46
+ *
47
+ * Uses raw mode directly because Node's readline always echoes input.
48
+ * Exported so the manual `clementine config keychain-fix-acl` command can
49
+ * use the same one-prompt UX.
50
+ */
51
+ export function readPasswordFromTty(prompt) {
52
+ return new Promise((resolve) => {
53
+ output.write(prompt);
54
+ let value = '';
55
+ const wasRaw = input.isRaw === true;
56
+ if (typeof input.setRawMode === 'function')
57
+ input.setRawMode(true);
58
+ input.resume();
59
+ input.setEncoding('utf8');
60
+ const finish = () => {
61
+ input.removeListener('data', onData);
62
+ if (typeof input.setRawMode === 'function')
63
+ input.setRawMode(wasRaw);
64
+ input.pause();
65
+ output.write('\n');
66
+ resolve(value);
67
+ };
68
+ const onData = (chunk) => {
69
+ // Raw stdin can deliver multiple chars per chunk (paste, escape seqs).
70
+ for (const char of chunk) {
71
+ const code = char.charCodeAt(0);
72
+ // Ctrl-C → abort the process entirely (user wants out).
73
+ if (code === 0x03) {
74
+ output.write('\n');
75
+ process.exit(130);
76
+ }
77
+ // Enter (LF/CR) or Ctrl-D → submit whatever we have.
78
+ if (code === 0x0a || code === 0x0d || code === 0x04) {
79
+ finish();
80
+ return;
81
+ }
82
+ // Backspace (DEL or BS).
83
+ if (code === 0x7f || code === 0x08) {
84
+ if (value.length > 0) {
85
+ value = value.slice(0, -1);
86
+ output.write('\b \b');
87
+ }
88
+ continue;
89
+ }
90
+ // Printable ASCII + UTF-8 — skip anything else (arrow keys, escape seqs).
91
+ if (code >= 0x20 && code !== 0x7f) {
92
+ value += char;
93
+ output.write('*');
94
+ }
95
+ }
96
+ };
97
+ input.on('data', onData);
98
+ });
99
+ }
41
100
  export async function runFirstRunKeychainWizardIfNeeded(baseDir) {
42
101
  if (process.platform !== 'darwin')
43
102
  return;
@@ -48,7 +107,6 @@ export async function runFirstRunKeychainWizardIfNeeded(baseDir) {
48
107
  const { listClementineKeychainEntries, fixAllClementineEntries } = await import('./keychain-fix-acl.js');
49
108
  const entries = listClementineKeychainEntries().filter((e) => e.isClementine);
50
109
  if (entries.length === 0) {
51
- // Nothing to fix — write sentinel so we don't re-scan every launch.
52
110
  markKeychainWizardDone(baseDir);
53
111
  return;
54
112
  }
@@ -62,10 +120,11 @@ export async function runFirstRunKeychainWizardIfNeeded(baseDir) {
62
120
  console.log(` ${BOLD}One-time keychain setup${RESET}`);
63
121
  console.log(` ${DIM}${entries.length} keychain entr${entries.length === 1 ? 'y' : 'ies'} from a previous version need an${RESET}`);
64
122
  console.log(` ${DIM}access-control update so Clementine can read them${RESET}`);
65
- console.log(` ${DIM}silently — otherwise macOS will prompt on every read.${RESET}`);
123
+ console.log(` ${DIM}silently — otherwise macOS prompts on every read.${RESET}`);
66
124
  console.log();
67
- console.log(` ${DIM}macOS will ask once for your login-keychain password.${RESET}`);
68
- console.log(` ${DIM}After that, no more prompts. We won't ask again.${RESET}`);
125
+ console.log(` ${DIM}You'll enter your macOS login password ONCE below —${RESET}`);
126
+ console.log(` ${DIM}it authorizes all ACL updates in a single pass.${RESET}`);
127
+ console.log(` ${DIM}Not stored. Won't ask again.${RESET}`);
69
128
  console.log();
70
129
  const rl = readline.createInterface({ input, output });
71
130
  let answer;
@@ -77,7 +136,15 @@ export async function runFirstRunKeychainWizardIfNeeded(baseDir) {
77
136
  }
78
137
  if (answer === 'n' || answer === 'no') {
79
138
  console.log();
80
- console.log(` ${YELLOW}Skipped.${RESET} ${DIM}Run later with: clementine config keychain-fix-acl${RESET}`);
139
+ console.log(` ${YELLOW}Skipped.${RESET} ${DIM}Run later: clementine config keychain-fix-acl${RESET}`);
140
+ console.log();
141
+ markKeychainWizardDone(baseDir);
142
+ return;
143
+ }
144
+ const password = await readPasswordFromTty(` ${BOLD}macOS login password:${RESET} `);
145
+ if (!password) {
146
+ console.log();
147
+ console.log(` ${YELLOW}Empty password — skipped.${RESET} ${DIM}Run later: clementine config keychain-fix-acl${RESET}`);
81
148
  console.log();
82
149
  markKeychainWizardDone(baseDir);
83
150
  return;
@@ -85,9 +152,10 @@ export async function runFirstRunKeychainWizardIfNeeded(baseDir) {
85
152
  console.log();
86
153
  console.log(` ${BOLD}Repairing ACLs...${RESET}`);
87
154
  console.log();
88
- const results = fixAllClementineEntries();
155
+ const results = fixAllClementineEntries({ keychainPassword: password });
89
156
  let okCount = 0;
90
157
  let failCount = 0;
158
+ let wrongPasswordHit = false;
91
159
  for (const r of results) {
92
160
  if (r.status === 'fixed') {
93
161
  console.log(` ${GREEN}✓${RESET} ${r.account}`);
@@ -96,20 +164,31 @@ export async function runFirstRunKeychainWizardIfNeeded(baseDir) {
96
164
  else if (r.status === 'failed') {
97
165
  console.log(` ${RED}✗${RESET} ${r.account} ${DIM}— ${r.error ?? 'unknown'}${RESET}`);
98
166
  failCount++;
167
+ // security's stderr for a bad password contains "MAC verification failed"
168
+ // or similar. Catch the common shapes so we can re-prompt next launch.
169
+ if (r.error && /MAC verification|AuthFailure|UserCanceled|-25293/i.test(r.error)) {
170
+ wrongPasswordHit = true;
171
+ }
99
172
  }
100
173
  }
101
174
  console.log();
102
175
  if (failCount === 0) {
103
176
  console.log(` ${GREEN}Done — ${okCount} entr${okCount === 1 ? 'y' : 'ies'} repaired.${RESET} ${DIM}Future reads silent.${RESET}`);
104
177
  }
178
+ else if (wrongPasswordHit && okCount === 0) {
179
+ console.log(` ${RED}Wrong password — no entries repaired.${RESET}`);
180
+ console.log(` ${DIM}We'll ask again on next launch.${RESET}`);
181
+ }
105
182
  else {
106
183
  console.log(` ${YELLOW}${okCount} fixed, ${failCount} failed.${RESET}`);
107
184
  console.log(` ${DIM}Failed entries can be fixed manually in Keychain Access.app:${RESET}`);
108
185
  console.log(` ${DIM} search "clementine-agent" → right-click → Get Info → Access Control.${RESET}`);
109
186
  }
110
187
  console.log();
111
- // Always mark done even on partial failure we don't want to re-prompt
112
- // every launch. The user can re-run the manual command if they want.
113
- markKeychainWizardDone(baseDir);
188
+ // Don't write sentinel if the password was wrong AND nothing succeeded
189
+ // let the user retry on the next launch. Any other outcome marks done.
190
+ if (!(wrongPasswordHit && okCount === 0)) {
191
+ markKeychainWizardDone(baseDir);
192
+ }
114
193
  }
115
194
  //# sourceMappingURL=keychain-first-run-wizard.js.map
@@ -51,12 +51,20 @@ export interface AclFixResult {
51
51
  * never touched (could be other apps that coincidentally chose that name).
52
52
  */
53
53
  export declare function listClementineKeychainEntries(): KeychainEntry[];
54
- export declare function fixAcl(service: Service, account: string): AclFixResult;
54
+ export declare function fixAcl(service: Service, account: string, opts?: {
55
+ keychainPassword?: string;
56
+ }): AclFixResult;
55
57
  /**
56
58
  * Plan + apply: enumerate entries, fix each Clementine-shaped one in turn.
57
59
  * Foreign entries (other apps under the legacy "clementine" service) get
58
60
  * reported with status='skipped-foreign' and never touched.
61
+ *
62
+ * Pass opts.keychainPassword to authorize all entries with one stored password
63
+ * instead of one TTY prompt per entry — that's how the wizard avoids the
64
+ * 7-prompt bombardment.
59
65
  */
60
- export declare function fixAllClementineEntries(): AclFixResult[];
66
+ export declare function fixAllClementineEntries(opts?: {
67
+ keychainPassword?: string;
68
+ }): AclFixResult[];
61
69
  export {};
62
70
  //# sourceMappingURL=keychain-fix-acl.d.ts.map
@@ -148,7 +148,7 @@ function locateKeychain(service, account) {
148
148
  const m = first.match(/^keychain:\s+"([^"]+)"/);
149
149
  return m ? m[1] : null;
150
150
  }
151
- export function fixAcl(service, account) {
151
+ export function fixAcl(service, account, opts = {}) {
152
152
  const keychainPath = locateKeychain(service, account);
153
153
  if (!keychainPath) {
154
154
  return {
@@ -160,33 +160,47 @@ export function fixAcl(service, account) {
160
160
  }
161
161
  // Pass the keychain path as the trailing positional arg so partition-list
162
162
  // doesn't search the wrong store.
163
- const args = [
163
+ const baseArgs = [
164
164
  'set-generic-password-partition-list',
165
165
  '-s', service,
166
166
  '-a', account,
167
167
  '-S', 'apple-tool:,apple:',
168
- keychainPath,
169
168
  ];
169
+ // When a password is provided, pass it via -k and capture stdio so security
170
+ // doesn't prompt — that's the whole point. Without -k, security prompts on
171
+ // the inherited TTY for each entry, which means N entries = N prompts.
172
+ const args = opts.keychainPassword
173
+ ? [...baseArgs, '-k', opts.keychainPassword, keychainPath]
174
+ : [...baseArgs, keychainPath];
170
175
  const result = spawnSync('/usr/bin/security', args, {
171
- stdio: 'inherit',
176
+ stdio: opts.keychainPassword ? ['ignore', 'pipe', 'pipe'] : 'inherit',
172
177
  timeout: 120_000,
173
178
  });
174
179
  if (result.status === 0) {
175
180
  return { service, account, status: 'fixed' };
176
181
  }
182
+ // When stdio was captured, surface stderr in the error so callers can
183
+ // detect "wrong password" vs other failures.
184
+ const stderr = opts.keychainPassword && result.stderr
185
+ ? result.stderr.toString().trim().slice(0, 200)
186
+ : '';
177
187
  return {
178
188
  service,
179
189
  account,
180
190
  status: 'failed',
181
- error: result.error?.message ?? `exit code ${result.status}`,
191
+ error: stderr || result.error?.message || `exit code ${result.status}`,
182
192
  };
183
193
  }
184
194
  /**
185
195
  * Plan + apply: enumerate entries, fix each Clementine-shaped one in turn.
186
196
  * Foreign entries (other apps under the legacy "clementine" service) get
187
197
  * reported with status='skipped-foreign' and never touched.
198
+ *
199
+ * Pass opts.keychainPassword to authorize all entries with one stored password
200
+ * instead of one TTY prompt per entry — that's how the wizard avoids the
201
+ * 7-prompt bombardment.
188
202
  */
189
- export function fixAllClementineEntries() {
203
+ export function fixAllClementineEntries(opts = {}) {
190
204
  const entries = listClementineKeychainEntries();
191
205
  const results = [];
192
206
  for (const entry of entries) {
@@ -194,7 +208,7 @@ export function fixAllClementineEntries() {
194
208
  results.push({ service: entry.service, account: entry.account, status: 'skipped-foreign' });
195
209
  continue;
196
210
  }
197
- results.push(fixAcl(entry.service, entry.account));
211
+ results.push(fixAcl(entry.service, entry.account, opts));
198
212
  }
199
213
  return results;
200
214
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Clementine TypeScript — Seed user-model slots from existing memory.
3
+ *
4
+ * One-shot Haiku pass over MEMORY.md + top-salience chunks + recent session
5
+ * summaries. Proposes initial values for the 4 user_model slots so a freshly-
6
+ * upgraded agent has a populated mental model immediately rather than needing
7
+ * to re-learn everything through fresh conversations.
8
+ *
9
+ * Returns proposals only — caller decides whether to apply (typically via the
10
+ * dashboard "Seed from existing memory" review UI).
11
+ */
12
+ interface SeedSourceStore {
13
+ searchFts(query: string, limit: number): Array<{
14
+ chunkId: number;
15
+ sourceFile: string;
16
+ section: string;
17
+ content: string;
18
+ salience: number;
19
+ }>;
20
+ getRecentSummaries(limit?: number): Array<{
21
+ summary: string;
22
+ createdAt: string;
23
+ }>;
24
+ db: unknown;
25
+ }
26
+ export interface UserModelProposals {
27
+ user_facts: string;
28
+ goals: string;
29
+ relationships: string;
30
+ agent_persona: string;
31
+ /** Number of distinct source items the LLM saw (chunks + summaries + MEMORY.md). */
32
+ sourceCount: number;
33
+ /** Raw model output, for debugging. */
34
+ rawResponse?: string;
35
+ }
36
+ export declare function seedUserModelFromMemory(store: SeedSourceStore, llmCall: (prompt: string) => Promise<string>): Promise<UserModelProposals>;
37
+ export {};
38
+ //# sourceMappingURL=seed-user-model.d.ts.map
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Clementine TypeScript — Seed user-model slots from existing memory.
3
+ *
4
+ * One-shot Haiku pass over MEMORY.md + top-salience chunks + recent session
5
+ * summaries. Proposes initial values for the 4 user_model slots so a freshly-
6
+ * upgraded agent has a populated mental model immediately rather than needing
7
+ * to re-learn everything through fresh conversations.
8
+ *
9
+ * Returns proposals only — caller decides whether to apply (typically via the
10
+ * dashboard "Seed from existing memory" review UI).
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import pino from 'pino';
14
+ import { MEMORY_FILE } from '../tools/shared.js';
15
+ const logger = pino({ name: 'clementine.seed-user-model' });
16
+ /** Size budget per source category — keeps the corpus under Haiku's
17
+ * prompt-cost sweet spot while preserving signal. */
18
+ const MAX_MEMORY_MD_CHARS = 4000;
19
+ const MAX_CHUNK_CHARS = 4000;
20
+ const MAX_SUMMARIES_CHARS = 1500;
21
+ function gatherCorpus(store) {
22
+ const parts = [];
23
+ let sourceCount = 0;
24
+ // 1. MEMORY.md — highest-signal source, the agent's curated profile note
25
+ if (existsSync(MEMORY_FILE)) {
26
+ try {
27
+ const md = readFileSync(MEMORY_FILE, 'utf-8').slice(0, MAX_MEMORY_MD_CHARS);
28
+ if (md.trim()) {
29
+ parts.push(`## MEMORY.md\n${md}`);
30
+ sourceCount++;
31
+ }
32
+ }
33
+ catch { /* non-fatal */ }
34
+ }
35
+ // 2. Top-salience chunks — what the agent has been actively retrieving
36
+ // We don't have a "list by salience" method on the store directly, but
37
+ // searchFts with a generic query that matches almost everything works as
38
+ // a coarse top-N proxy. Better: query the underlying db handle.
39
+ try {
40
+ const db = store.db;
41
+ const rows = db.prepare(`SELECT c.source_file, c.section, c.content, c.salience
42
+ FROM chunks c
43
+ LEFT JOIN chunk_soft_deletes sd ON sd.chunk_id = c.id
44
+ WHERE sd.chunk_id IS NULL AND length(c.content) > 50
45
+ ORDER BY c.salience DESC, c.last_outcome_score DESC, c.updated_at DESC
46
+ LIMIT 40`).all();
47
+ if (rows.length > 0) {
48
+ let chunkBlock = '## High-salience memory chunks\n';
49
+ let used = 0;
50
+ for (const r of rows) {
51
+ const entry = `\n--- [${r.source_file} · ${r.section}]\n${r.content.slice(0, 500)}\n`;
52
+ if (used + entry.length > MAX_CHUNK_CHARS)
53
+ break;
54
+ chunkBlock += entry;
55
+ used += entry.length;
56
+ sourceCount++;
57
+ }
58
+ parts.push(chunkBlock);
59
+ }
60
+ }
61
+ catch (err) {
62
+ logger.debug({ err }, 'Failed to gather salience chunks');
63
+ }
64
+ // 3. Recent session summaries — surfaces current goals and active context
65
+ try {
66
+ const summaries = store.getRecentSummaries(8);
67
+ if (summaries.length > 0) {
68
+ let block = '## Recent session summaries\n';
69
+ let used = 0;
70
+ for (const s of summaries) {
71
+ const entry = `\n[${s.createdAt}]\n${s.summary.slice(0, 400)}\n`;
72
+ if (used + entry.length > MAX_SUMMARIES_CHARS)
73
+ break;
74
+ block += entry;
75
+ used += entry.length;
76
+ sourceCount++;
77
+ }
78
+ parts.push(block);
79
+ }
80
+ }
81
+ catch { /* getRecentSummaries may be missing or empty */ }
82
+ return { corpus: parts.join('\n\n---\n\n'), sourceCount };
83
+ }
84
+ const SEED_PROMPT = (corpus) => `Analyze the memory corpus below and propose initial values for the agent's "user model" — coherent always-in-context facts about the user that load into every conversation.
85
+
86
+ The user model has 4 slots, each capped at 2000 chars:
87
+
88
+ 1. **user_facts** — Who they are. Name, role, location, lasting preferences (writing style, tools, communication style). Stable identifiers and personality traits.
89
+ 2. **goals** — What they're actively working toward right now. Current projects, deadlines, immediate intents. Skip vague long-term aspirations.
90
+ 3. **relationships** — People, projects, channels they regularly interact with. One line each, who-is-what.
91
+ 4. **agent_persona** — How the agent (Clementine) should think of itself in relation to the user. Skip if no signal.
92
+
93
+ Rules:
94
+ - Only include facts with direct evidence in the corpus
95
+ - Be terse. Bullet fragments, not full sentences. Skip filler.
96
+ - If a slot has no good signal, output exactly: "(no clear signal)"
97
+ - Never invent details — if uncertain, leave it out
98
+ - Don't repeat the same fact across slots
99
+
100
+ Output exactly this format (markdown headings, no other text before or after):
101
+
102
+ ## user_facts
103
+ - bullet
104
+ - bullet
105
+
106
+ ## goals
107
+ - bullet
108
+
109
+ ## relationships
110
+ - bullet
111
+
112
+ ## agent_persona
113
+ - bullet
114
+
115
+ ---
116
+
117
+ Memory corpus to analyze:
118
+
119
+ ${corpus}`;
120
+ function parseProposals(raw) {
121
+ const slots = ['user_facts', 'goals', 'relationships', 'agent_persona'];
122
+ const out = {
123
+ user_facts: '', goals: '', relationships: '', agent_persona: '',
124
+ };
125
+ // Tolerant parser: split on any "## slot_name" header (case-insensitive),
126
+ // anchored at line start. Whatever follows up to the next slot header is
127
+ // that slot's content.
128
+ for (let i = 0; i < slots.length; i++) {
129
+ const slot = slots[i];
130
+ const next = slots[i + 1];
131
+ const startRe = new RegExp(`^\\s*##\\s+${slot}\\b.*$`, 'mi');
132
+ const m = startRe.exec(raw);
133
+ if (!m)
134
+ continue;
135
+ let endIdx = raw.length;
136
+ if (next) {
137
+ const endRe = new RegExp(`^\\s*##\\s+${next}\\b.*$`, 'mi');
138
+ const e = endRe.exec(raw);
139
+ if (e && e.index > m.index)
140
+ endIdx = e.index;
141
+ }
142
+ let body = raw.slice(m.index + m[0].length, endIdx).trim();
143
+ // Treat "(no clear signal)" as empty so the slot stays unset
144
+ if (/^\s*\(?no\s+clear\s+signal\)?\s*$/i.test(body))
145
+ body = '';
146
+ out[slot] = body.slice(0, 2000);
147
+ }
148
+ return out;
149
+ }
150
+ export async function seedUserModelFromMemory(store, llmCall) {
151
+ const { corpus, sourceCount } = gatherCorpus(store);
152
+ if (!corpus.trim() || sourceCount === 0) {
153
+ return {
154
+ user_facts: '', goals: '', relationships: '', agent_persona: '',
155
+ sourceCount: 0,
156
+ rawResponse: 'No source material found — vault may be empty.',
157
+ };
158
+ }
159
+ const prompt = SEED_PROMPT(corpus);
160
+ let raw = '';
161
+ try {
162
+ raw = await llmCall(prompt);
163
+ }
164
+ catch (err) {
165
+ logger.warn({ err }, 'Seed LLM call failed');
166
+ return {
167
+ user_facts: '', goals: '', relationships: '', agent_persona: '',
168
+ sourceCount, rawResponse: `LLM call failed: ${String(err)}`,
169
+ };
170
+ }
171
+ const parsed = parseProposals(raw);
172
+ return { ...parsed, sourceCount, rawResponse: raw };
173
+ }
174
+ //# sourceMappingURL=seed-user-model.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",