personal-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/entry.mjs ADDED
@@ -0,0 +1,3891 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import crypto from "node:crypto";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import os from "node:os";
7
+ import http from "node:http";
8
+ import { URL } from "node:url";
9
+ import { google } from "googleapis";
10
+ import open from "open";
11
+ import chalk from "chalk";
12
+ import ora from "ora";
13
+ import JSON5 from "json5";
14
+ import { z } from "zod";
15
+ import { execFile } from "node:child_process";
16
+ import matter from "gray-matter";
17
+ import OpenAI from "openai";
18
+ import { minimatch } from "minimatch";
19
+ import readline from "node:readline";
20
+ import { Agent } from "@mariozechner/pi-agent-core";
21
+ import { getModel } from "@mariozechner/pi-ai";
22
+ import { Type } from "@sinclair/typebox";
23
+
24
+ //#region src/config/paths.ts
25
+ /** Root data directory for pai */
26
+ function getPaiHome() {
27
+ return process.env.PAI_HOME ?? path.join(os.homedir(), ".pai");
28
+ }
29
+ function getRawDir() {
30
+ return path.join(getPaiHome(), "raw");
31
+ }
32
+ function getVaultDir() {
33
+ return path.join(getPaiHome(), "vault");
34
+ }
35
+ function getSkillsDir() {
36
+ return path.join(getPaiHome(), "skills", "profiles");
37
+ }
38
+ function getConfigDir() {
39
+ return path.join(getPaiHome(), "config");
40
+ }
41
+ function getConfigPath() {
42
+ return path.join(getConfigDir(), "pai.json5");
43
+ }
44
+ function getProfilesPath() {
45
+ return path.join(getConfigDir(), "profiles.json5");
46
+ }
47
+ function getPreferencesPath() {
48
+ return path.join(getConfigDir(), "preferences.md");
49
+ }
50
+ /** Compiled user profile — the core output of pai */
51
+ function getProfilePath() {
52
+ return path.join(getPaiHome(), "profile.md");
53
+ }
54
+
55
+ //#endregion
56
+ //#region src/auth/encryption.ts
57
+ const ALGORITHM = "aes-256-gcm";
58
+ const KEY_LENGTH = 32;
59
+ const IV_LENGTH = 16;
60
+ var Encryption = class {
61
+ keyPath;
62
+ key = null;
63
+ constructor() {
64
+ this.keyPath = path.join(getPaiHome(), ".encryption-key");
65
+ }
66
+ async generateKey() {
67
+ const key = crypto.randomBytes(KEY_LENGTH);
68
+ await fs.mkdir(path.dirname(this.keyPath), { recursive: true });
69
+ await fs.writeFile(this.keyPath, key.toString("hex"), { mode: 384 });
70
+ this.key = key;
71
+ }
72
+ async loadKey() {
73
+ try {
74
+ const keyHex = await fs.readFile(this.keyPath, "utf-8");
75
+ this.key = Buffer.from(keyHex.trim(), "hex");
76
+ } catch (err) {
77
+ if (err.code === "ENOENT") await this.generateKey();
78
+ else throw err;
79
+ }
80
+ }
81
+ ensureKey() {
82
+ if (!this.key) throw new Error("Encryption key not loaded. Call loadKey() first.");
83
+ return this.key;
84
+ }
85
+ encrypt(data) {
86
+ const key = this.ensureKey();
87
+ const iv = crypto.randomBytes(IV_LENGTH);
88
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
89
+ let encrypted = cipher.update(data, "utf8", "hex");
90
+ encrypted += cipher.final("hex");
91
+ const authTag = cipher.getAuthTag();
92
+ return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted;
93
+ }
94
+ decrypt(encryptedData) {
95
+ const key = this.ensureKey();
96
+ const parts = encryptedData.split(":");
97
+ if (parts.length !== 3) throw new Error("Invalid encrypted data format");
98
+ const iv = Buffer.from(parts[0], "hex");
99
+ const authTag = Buffer.from(parts[1], "hex");
100
+ const encrypted = parts[2];
101
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
102
+ decipher.setAuthTag(authTag);
103
+ let decrypted = decipher.update(encrypted, "hex", "utf8");
104
+ decrypted += decipher.final("utf8");
105
+ return decrypted;
106
+ }
107
+ async encryptFile(filePath, data) {
108
+ const jsonData = JSON.stringify(data, null, 2);
109
+ const encrypted = this.encrypt(jsonData);
110
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
111
+ await fs.writeFile(filePath, encrypted, { mode: 384 });
112
+ }
113
+ async decryptFile(filePath) {
114
+ const encrypted = await fs.readFile(filePath, "utf-8");
115
+ const decrypted = this.decrypt(encrypted);
116
+ return JSON.parse(decrypted);
117
+ }
118
+ };
119
+ const encryption = new Encryption();
120
+
121
+ //#endregion
122
+ //#region src/utils/console.ts
123
+ function log(message) {
124
+ console.log(message);
125
+ }
126
+ function info(message) {
127
+ console.log(chalk.blue("ℹ"), message);
128
+ }
129
+ function success(message) {
130
+ console.log(chalk.green("✓"), message);
131
+ }
132
+ function warn(message) {
133
+ console.warn(chalk.yellow("⚠"), message);
134
+ }
135
+ function error(message) {
136
+ console.error(chalk.red("✗"), message);
137
+ }
138
+ function spinner(text) {
139
+ return ora({
140
+ text,
141
+ color: "cyan"
142
+ }).start();
143
+ }
144
+ function dim(message) {
145
+ return chalk.dim(message);
146
+ }
147
+ function bold(message) {
148
+ return chalk.bold(message);
149
+ }
150
+
151
+ //#endregion
152
+ //#region src/auth/google-oauth.ts
153
+ const SCOPES = [
154
+ "https://www.googleapis.com/auth/gmail.readonly",
155
+ "https://www.googleapis.com/auth/gmail.send",
156
+ "https://www.googleapis.com/auth/calendar.readonly"
157
+ ];
158
+ const REDIRECT_URI = "http://localhost:8888/callback";
159
+ const CALLBACK_TIMEOUT_MS = 120 * 1e3;
160
+ /**
161
+ * Embedded OAuth client credentials for Desktop/CLI app.
162
+ * Google explicitly states that for "installed" (desktop) applications,
163
+ * the client_secret is NOT treated as a secret.
164
+ * Users can override by placing their own client_secret.json in ~/.pai/credentials/.
165
+ */
166
+ const DEFAULT_CLIENT_SECRETS = { installed: {
167
+ client_id: "973253759191-ihev26p7n9t1faksavdei36fbii46js7.apps.googleusercontent.com",
168
+ project_id: "pinai-428200",
169
+ auth_uri: "https://accounts.google.com/o/oauth2/auth",
170
+ token_uri: "https://oauth2.googleapis.com/token",
171
+ auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs",
172
+ client_secret: "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",
173
+ redirect_uris: ["http://localhost"]
174
+ } };
175
+ var GoogleOAuth = class {
176
+ oauth2Client = null;
177
+ credentialsPath;
178
+ clientSecretsPath;
179
+ constructor() {
180
+ const baseDir = path.join(getPaiHome(), "credentials");
181
+ this.credentialsPath = path.join(baseDir, "google-oauth.json.enc");
182
+ this.clientSecretsPath = path.join(baseDir, "client_secret.json");
183
+ }
184
+ async init() {
185
+ let clientSecrets = null;
186
+ try {
187
+ const content = await fs.readFile(this.clientSecretsPath, "utf-8");
188
+ clientSecrets = JSON.parse(content);
189
+ } catch {
190
+ const fromCwd = path.join(process.cwd(), "credentials", "client_secret.json");
191
+ try {
192
+ const content = await fs.readFile(fromCwd, "utf-8");
193
+ clientSecrets = JSON.parse(content);
194
+ await fs.mkdir(path.dirname(this.clientSecretsPath), { recursive: true });
195
+ await fs.copyFile(fromCwd, this.clientSecretsPath);
196
+ info(`Copied credentials from ${fromCwd} to ~/.pai/credentials/`);
197
+ } catch {}
198
+ }
199
+ if (!clientSecrets) clientSecrets = DEFAULT_CLIENT_SECRETS;
200
+ try {
201
+ const { client_id, client_secret } = clientSecrets.installed ?? clientSecrets.web ?? {};
202
+ if (!client_id || !client_secret) throw new Error("Missing client_id or client_secret in client_secret.json");
203
+ this.oauth2Client = new google.auth.OAuth2(client_id, client_secret, REDIRECT_URI);
204
+ await this.loadCredentials();
205
+ } catch {
206
+ warn("OAuth client initialization failed. Run: pai auth google");
207
+ }
208
+ }
209
+ async loadCredentials() {
210
+ try {
211
+ await encryption.loadKey();
212
+ const credentials = await encryption.decryptFile(this.credentialsPath);
213
+ this.oauth2Client.setCredentials(credentials);
214
+ this.oauth2Client.on("tokens", (tokens) => {
215
+ if (tokens && typeof tokens === "object" && "refresh_token" in tokens && tokens.refresh_token) this.saveCredentials(tokens);
216
+ });
217
+ } catch {}
218
+ }
219
+ async saveCredentials(credentials) {
220
+ await encryption.loadKey();
221
+ await encryption.encryptFile(this.credentialsPath, credentials);
222
+ }
223
+ async authorize() {
224
+ if (!this.oauth2Client) throw new Error("OAuth client not initialized. Place client_secret.json in ~/.pai/credentials/ and run again.");
225
+ const state = crypto.randomBytes(16).toString("hex");
226
+ const authUrl = this.oauth2Client.generateAuthUrl({
227
+ access_type: "offline",
228
+ scope: SCOPES,
229
+ prompt: "consent",
230
+ state
231
+ });
232
+ log("\nPlease visit this URL to authorize the application:\n");
233
+ log(authUrl);
234
+ log("\nOpening browser...\n");
235
+ await open(authUrl);
236
+ const code = await this.startCallbackServer(state);
237
+ const { tokens } = await this.oauth2Client.getToken(code);
238
+ this.oauth2Client.setCredentials(tokens);
239
+ await this.saveCredentials(tokens);
240
+ success("Google OAuth authorization successful.");
241
+ }
242
+ startCallbackServer(expectedState) {
243
+ return new Promise((resolve, reject) => {
244
+ const server = http.createServer((req, res) => {
245
+ if (req.url?.startsWith("/callback")) {
246
+ const url = new URL(req.url ?? "", `http://${req.headers.host}`);
247
+ const code = url.searchParams.get("code");
248
+ const state = url.searchParams.get("state");
249
+ if (code && state === expectedState) {
250
+ res.writeHead(200, { "Content-Type": "text/html" });
251
+ res.end("<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>");
252
+ server.close();
253
+ resolve(code);
254
+ } else {
255
+ res.writeHead(400, { "Content-Type": "text/plain" });
256
+ res.end("Authorization failed: Invalid callback parameters");
257
+ server.close();
258
+ reject(/* @__PURE__ */ new Error("Authorization failed: invalid callback parameters"));
259
+ }
260
+ }
261
+ });
262
+ server.listen(8888, () => {});
263
+ server.on("error", (err) => {
264
+ reject(err);
265
+ });
266
+ const timeout = setTimeout(() => {
267
+ server.close();
268
+ reject(/* @__PURE__ */ new Error("Authorization timed out. Please retry: pai auth google"));
269
+ }, CALLBACK_TIMEOUT_MS);
270
+ server.on("close", () => {
271
+ clearTimeout(timeout);
272
+ });
273
+ });
274
+ }
275
+ getClient() {
276
+ if (!this.oauth2Client) throw new Error("OAuth client not initialized");
277
+ return this.oauth2Client;
278
+ }
279
+ async ensureAuthenticated() {
280
+ if (!this.oauth2Client || !this.oauth2Client.credentials.access_token) throw new Error("Not authenticated. Run: pai auth google");
281
+ const now = Date.now();
282
+ const expiry = this.oauth2Client.credentials.expiry_date;
283
+ if (expiry != null && expiry < now) await this.oauth2Client.refreshAccessToken();
284
+ }
285
+ };
286
+ const googleOAuth = new GoogleOAuth();
287
+
288
+ //#endregion
289
+ //#region src/cli/register.auth.ts
290
+ function registerAuthCommand(program) {
291
+ program.command("auth").description("Authenticate with Google (for gmail/calendar connectors)").argument("<provider>", "Provider (google)").action(async (provider) => {
292
+ if (provider !== "google") {
293
+ error("Only \"google\" provider is supported.");
294
+ process.exit(1);
295
+ }
296
+ try {
297
+ await encryption.loadKey();
298
+ await googleOAuth.init();
299
+ await googleOAuth.authorize();
300
+ success("Authentication successful.");
301
+ } catch (err) {
302
+ const msg = err instanceof Error ? err.message : String(err);
303
+ error(`Authentication failed: ${msg}`);
304
+ process.exit(1);
305
+ }
306
+ });
307
+ }
308
+
309
+ //#endregion
310
+ //#region src/config/defaults.ts
311
+ /** Default content for pai.json5 */
312
+ const DEFAULT_CONFIG = `{
313
+ // pai configuration
314
+ version: "0.1",
315
+
316
+ qmd: {
317
+ rawCollection: "raw",
318
+ vaultCollection: "vault",
319
+ },
320
+
321
+ llm: {
322
+ apiKeyEnv: "OPENAI_API_KEY",
323
+ baseUrl: "", // leave empty for OpenAI, or set for compatible services
324
+ cheapModel: "gpt-4o-mini",
325
+ expensiveModel: "gpt-4o",
326
+ },
327
+
328
+ scraper: {
329
+ timeout: 30000,
330
+ },
331
+
332
+ vault: {
333
+ maxFileTokens: 4000,
334
+ warnFileTokens: 3000,
335
+ },
336
+ }
337
+ `;
338
+ /** Default content for preferences.md — injected into ALL LLM calls */
339
+ const DEFAULT_PREFERENCES = `# User Preferences
340
+
341
+ These preferences are automatically included in every AI operation (triage, distill, generate).
342
+ Edit this file directly — changes take effect on the next pai command.
343
+
344
+ ## Language
345
+
346
+ - My native language is: 中文 (Chinese)
347
+ - All distilled knowledge and vault content should prefer: 中文
348
+ - Raw data should be preserved in its original language
349
+ - SKILL.md profiles should be written in the same language as the vault content
350
+
351
+ ## Output Style
352
+
353
+ - Be concise and actionable — no filler
354
+ - Use bullet points for experience entries
355
+ - Preserve technical terms in English (e.g. API, CORS, TypeScript)
356
+ `;
357
+ /** Default content for profiles.json5 */
358
+ const DEFAULT_PROFILES = `{
359
+ profiles: {
360
+ "coding-assistant": {
361
+ scope: [
362
+ "vault/coding/**",
363
+ "vault/preferences/**",
364
+ "vault/context/**",
365
+ ],
366
+ maxLines: 60,
367
+ },
368
+
369
+ "full-context": {
370
+ scope: ["vault/**"],
371
+ maxLines: 120,
372
+ },
373
+
374
+ "life-assistant": {
375
+ scope: [
376
+ "vault/life/**",
377
+ "vault/work/**",
378
+ "vault/preferences/**",
379
+ "vault/context/**",
380
+ ],
381
+ maxLines: 60,
382
+ },
383
+ },
384
+ }
385
+ `;
386
+
387
+ //#endregion
388
+ //#region src/config/schema.ts
389
+ const QmdConfigSchema = z.object({
390
+ rawCollection: z.string().default("raw"),
391
+ vaultCollection: z.string().default("vault")
392
+ });
393
+ const LlmConfigSchema = z.object({
394
+ apiKeyEnv: z.string().default("OPENAI_API_KEY"),
395
+ baseUrl: z.string().optional(),
396
+ cheapModel: z.string().default("gpt-4o-mini"),
397
+ expensiveModel: z.string().default("gpt-4o")
398
+ });
399
+ const ScraperConfigSchema = z.object({ timeout: z.number().default(3e4) });
400
+ const VaultConfigSchema = z.object({
401
+ maxFileTokens: z.number().default(4e3),
402
+ warnFileTokens: z.number().default(3e3)
403
+ });
404
+ const PaiConfigSchema = z.object({
405
+ version: z.string().default("0.1"),
406
+ qmd: QmdConfigSchema.default(() => QmdConfigSchema.parse({})),
407
+ llm: LlmConfigSchema.default(() => LlmConfigSchema.parse({})),
408
+ scraper: ScraperConfigSchema.default(() => ScraperConfigSchema.parse({})),
409
+ vault: VaultConfigSchema.default(() => VaultConfigSchema.parse({}))
410
+ }).strict();
411
+
412
+ //#endregion
413
+ //#region src/config/io.ts
414
+ /** Load and validate pai.json5 config */
415
+ async function loadConfig() {
416
+ const configPath = getConfigPath();
417
+ try {
418
+ const raw = await fs.readFile(configPath, "utf-8");
419
+ const parsed = JSON5.parse(raw);
420
+ return PaiConfigSchema.parse(parsed);
421
+ } catch (err) {
422
+ if (err.code === "ENOENT") return PaiConfigSchema.parse({});
423
+ throw err;
424
+ }
425
+ }
426
+ /** Load profiles.json5 */
427
+ async function loadProfiles() {
428
+ const profilesPath = getProfilesPath();
429
+ try {
430
+ const raw = await fs.readFile(profilesPath, "utf-8");
431
+ return JSON5.parse(raw);
432
+ } catch (err) {
433
+ if (err.code === "ENOENT") return { profiles: {} };
434
+ throw err;
435
+ }
436
+ }
437
+ /** Write config file (with JSON5 formatting) */
438
+ async function saveConfig(filePath, content) {
439
+ await fs.writeFile(filePath, content, "utf-8");
440
+ }
441
+
442
+ //#endregion
443
+ //#region src/utils/process.ts
444
+ /** Execute QMD CLI command and return stdout */
445
+ async function execQmd(args) {
446
+ return new Promise((resolve, reject) => {
447
+ execFile("qmd", args, {
448
+ encoding: "utf-8",
449
+ timeout: 6e4
450
+ }, (err, stdout, stderr) => {
451
+ if (err) {
452
+ const msg = stderr?.trim() || err.message;
453
+ reject(/* @__PURE__ */ new Error(`qmd ${args[0]} failed: ${msg}`));
454
+ } else resolve(stdout);
455
+ });
456
+ });
457
+ }
458
+ /** Check if QMD is available on PATH */
459
+ async function isQmdAvailable() {
460
+ try {
461
+ await execQmd(["status"]);
462
+ return true;
463
+ } catch {
464
+ return false;
465
+ }
466
+ }
467
+
468
+ //#endregion
469
+ //#region src/utils/slug.ts
470
+ /** Generate a URL-safe slug from a title string */
471
+ function generateSlug(title) {
472
+ return title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 50);
473
+ }
474
+ /** Generate a timestamped filename: YYYY-MM-DDTHH-MM-{slug}.md */
475
+ function generateRawFilename(title) {
476
+ return `${(/* @__PURE__ */ new Date()).toISOString().replace(/:\d{2}\.\d{3}Z$/, "").replace(/:/g, "-")}-${generateSlug(title) || "untitled"}.md`;
477
+ }
478
+
479
+ //#endregion
480
+ //#region src/utils/frontmatter.ts
481
+ /** Parse a markdown file with frontmatter */
482
+ function parseFrontmatter(content) {
483
+ const result = matter(content);
484
+ return {
485
+ data: result.data,
486
+ content: result.content
487
+ };
488
+ }
489
+ /** Stringify data + content into a frontmatter markdown string */
490
+ function stringifyFrontmatter(data, content) {
491
+ return matter.stringify(content, data);
492
+ }
493
+ /** Create a raw file with proper frontmatter */
494
+ function createRawFile(frontmatter, title, body) {
495
+ return stringifyFrontmatter(frontmatter, `\n# ${title}\n\n${body}\n`);
496
+ }
497
+ /** Update frontmatter fields in a raw file */
498
+ function updateRawFrontmatter(fileContent, updates) {
499
+ const { data, content } = parseFrontmatter(fileContent);
500
+ return stringifyFrontmatter({
501
+ ...data,
502
+ ...updates
503
+ }, content);
504
+ }
505
+
506
+ //#endregion
507
+ //#region src/raw/add.ts
508
+ /** Add plain text content to raw/local/ */
509
+ async function addText(content, source = "local") {
510
+ const title = extractTitle$1(content);
511
+ const filename = generateRawFilename(title);
512
+ const dir = path.join(getRawDir(), "local");
513
+ await fs.mkdir(dir, { recursive: true });
514
+ const fileContent = createRawFile({
515
+ source,
516
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
517
+ status: "pending"
518
+ }, title, content);
519
+ const filePath = path.join(dir, filename);
520
+ await fs.writeFile(filePath, fileContent, "utf-8");
521
+ return filePath;
522
+ }
523
+ /** Add a local file's content to raw/local/ */
524
+ async function addFile(filePath, source = "local") {
525
+ const content = await fs.readFile(filePath, "utf-8");
526
+ const basename = path.basename(filePath, path.extname(filePath));
527
+ const filename = generateRawFilename(basename);
528
+ const dir = path.join(getRawDir(), "local");
529
+ await fs.mkdir(dir, { recursive: true });
530
+ const fileContent = createRawFile({
531
+ source,
532
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
533
+ status: "pending"
534
+ }, basename, content);
535
+ const outPath = path.join(dir, filename);
536
+ await fs.writeFile(outPath, fileContent, "utf-8");
537
+ return outPath;
538
+ }
539
+ /** Add URL-scraped content to raw/web/ */
540
+ async function addUrl(url, title, markdown) {
541
+ const filename = generateRawFilename(title);
542
+ const dir = path.join(getRawDir(), "web");
543
+ await fs.mkdir(dir, { recursive: true });
544
+ const fileContent = createRawFile({
545
+ source: "web",
546
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
547
+ url,
548
+ status: "pending"
549
+ }, title, markdown);
550
+ const filePath = path.join(dir, filename);
551
+ await fs.writeFile(filePath, fileContent, "utf-8");
552
+ return filePath;
553
+ }
554
+ /**
555
+ * Add or update a connector-scanned entry to raw/connector/{name}/.
556
+ * Uses scan_id in frontmatter for dedup:
557
+ * - If no existing file with same scan_id → create new
558
+ * - If existing file with same content body → skip ("skipped")
559
+ * - If existing file with different content → overwrite ("updated")
560
+ * Returns "created" | "updated" | "skipped".
561
+ */
562
+ async function addConnectorEntry(connectorName, entry) {
563
+ const dir = path.join(getRawDir(), "connector", connectorName);
564
+ await fs.mkdir(dir, { recursive: true });
565
+ const scanId = `${connectorName}/${entry.id}`;
566
+ const existingPath = await findByScanId(dir, scanId);
567
+ const fm = {
568
+ source: `connector/${connectorName}`,
569
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
570
+ status: "pending",
571
+ scan_id: scanId
572
+ };
573
+ if (existingPath) {
574
+ const { content: oldBody } = parseFrontmatter(await fs.readFile(existingPath, "utf-8"));
575
+ const newBody = `\n# ${entry.title}\n\n${entry.content}\n`;
576
+ if (oldBody.trim() === newBody.trim()) return "skipped";
577
+ const fileContent = createRawFile(fm, entry.title, entry.content);
578
+ await fs.writeFile(existingPath, fileContent, "utf-8");
579
+ return "updated";
580
+ }
581
+ const filename = generateRawFilename(entry.id);
582
+ const fileContent = createRawFile(fm, entry.title, entry.content);
583
+ const filePath = path.join(dir, filename);
584
+ await fs.writeFile(filePath, fileContent, "utf-8");
585
+ return "created";
586
+ }
587
+ /** Find a raw file in a directory whose frontmatter scan_id matches */
588
+ async function findByScanId(dir, scanId) {
589
+ try {
590
+ const entries = await fs.readdir(dir);
591
+ for (const name of entries) {
592
+ if (!name.endsWith(".md")) continue;
593
+ const filePath = path.join(dir, name);
594
+ const content = await fs.readFile(filePath, "utf-8");
595
+ if (content.includes(`scan_id: ${scanId}`) || content.includes(`scan_id: '${scanId}'`)) return filePath;
596
+ }
597
+ } catch {}
598
+ return null;
599
+ }
600
+ /** List all pending raw files (status: pending) */
601
+ async function listPending() {
602
+ const rawDir = getRawDir();
603
+ const pending = [];
604
+ for (const sub of [
605
+ "local",
606
+ "web",
607
+ "connector"
608
+ ]) {
609
+ const dir = path.join(rawDir, sub);
610
+ try {
611
+ const entries = await walkDir(dir);
612
+ for (const entry of entries) if (entry.endsWith(".md")) {
613
+ if ((await fs.readFile(entry, "utf-8")).includes("status: pending")) pending.push(entry);
614
+ }
615
+ } catch {}
616
+ }
617
+ return pending;
618
+ }
619
+ /** List all raw files regardless of status */
620
+ async function listAll() {
621
+ const rawDir = getRawDir();
622
+ const files = [];
623
+ for (const sub of [
624
+ "local",
625
+ "web",
626
+ "connector"
627
+ ]) {
628
+ const dir = path.join(rawDir, sub);
629
+ try {
630
+ const entries = await walkDir(dir);
631
+ files.push(...entries.filter((e) => e.endsWith(".md")));
632
+ } catch {}
633
+ }
634
+ return files;
635
+ }
636
+ /** Recursively walk a directory and return file paths */
637
+ async function walkDir(dir) {
638
+ const results = [];
639
+ try {
640
+ const entries = await fs.readdir(dir, { withFileTypes: true });
641
+ for (const entry of entries) {
642
+ const fullPath = path.join(dir, entry.name);
643
+ if (entry.isDirectory()) results.push(...await walkDir(fullPath));
644
+ else results.push(fullPath);
645
+ }
646
+ } catch {}
647
+ return results;
648
+ }
649
+ /** Extract a title from content (first line or first N words) */
650
+ function extractTitle$1(content) {
651
+ const cleaned = (content.split("\n")[0]?.trim() ?? "").replace(/^#+\s*/, "");
652
+ if (cleaned.length > 0 && cleaned.length <= 100) return cleaned;
653
+ return content.split(/\s+/).slice(0, 8).join(" ").slice(0, 100) || "untitled";
654
+ }
655
+
656
+ //#endregion
657
+ //#region src/profile/compile.ts
658
+ const PROFILE_SECTIONS = [
659
+ {
660
+ heading: "Identity",
661
+ collectors: ["identity-profile", "github-profile"]
662
+ },
663
+ {
664
+ heading: "Environment & Tools",
665
+ collectors: ["dev-environment", "dev-preferences"]
666
+ },
667
+ {
668
+ heading: "Work Style & Habits",
669
+ collectors: ["shell-habits", "coding-rules"]
670
+ },
671
+ {
672
+ heading: "Active Projects & Recent Focus",
673
+ collectors: ["active-projects", "recent-focus"]
674
+ },
675
+ {
676
+ heading: "Digital Footprint",
677
+ collectors: [
678
+ "browser-bookmarks",
679
+ "browser-domains",
680
+ "productivity-setup"
681
+ ]
682
+ },
683
+ {
684
+ heading: "Registry & Cloud Accounts",
685
+ collectors: ["social-profiles"]
686
+ },
687
+ {
688
+ heading: "Context",
689
+ collectors: ["calendar-context", "file-organization"]
690
+ }
691
+ ];
692
+ /**
693
+ * Compile CollectorResult[] into a structured profile markdown string.
694
+ * No LLM involved — pure formatting.
695
+ */
696
+ function compileProfile(results) {
697
+ const index = /* @__PURE__ */ new Map();
698
+ for (const r of results) index.set(r.id, r);
699
+ const sections = ["# Personal Profile", ""];
700
+ for (const section of PROFILE_SECTIONS) {
701
+ const parts = [];
702
+ for (const cid of section.collectors) {
703
+ const result = index.get(cid);
704
+ if (result?.content?.trim()) parts.push(result.content.trim());
705
+ }
706
+ if (parts.length === 0) continue;
707
+ sections.push(`## ${section.heading}`, "");
708
+ sections.push(parts.join("\n\n"));
709
+ sections.push("");
710
+ }
711
+ const coveredIds = new Set(PROFILE_SECTIONS.flatMap((s) => s.collectors));
712
+ const extras = [];
713
+ for (const r of results) if (!coveredIds.has(r.id) && r.content?.trim()) extras.push(`## ${r.title}`, "", r.content.trim(), "");
714
+ if (extras.length > 0) sections.push(...extras);
715
+ sections.push("---");
716
+ sections.push(`Last updated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
717
+ sections.push("");
718
+ return sections.join("\n");
719
+ }
720
+
721
+ //#endregion
722
+ //#region src/connectors/sanitize.ts
723
+ /**
724
+ * Security sanitization utilities for connector-scanned content.
725
+ * Strips secrets, credentials, and sensitive paths before writing to raw layer.
726
+ */
727
+ /** Pattern matching shell export lines containing secrets */
728
+ const SECRET_EXPORT_RE = /^(\s*export\s+)\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|PASSWD)\w*\s*=.*/i;
729
+ /** Pattern matching lines that look like inline secret assignments (no export) */
730
+ const SECRET_ASSIGN_RE = /^\s*\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|PASSWD)\w*\s*=\s*["']?\S+/i;
731
+ /**
732
+ * Strip secret export/assignment lines from shell config content.
733
+ * Preserves comments and non-secret lines intact.
734
+ */
735
+ function stripSecretExports(content) {
736
+ return content.split("\n").map((line) => {
737
+ if (SECRET_EXPORT_RE.test(line) || SECRET_ASSIGN_RE.test(line)) return "# [REDACTED by pai — secret removed]";
738
+ return line;
739
+ }).join("\n");
740
+ }
741
+ /**
742
+ * Strip [credential] sections from git config content.
743
+ * Removes the section header and all lines until the next section.
744
+ */
745
+ function stripGitCredentials(content) {
746
+ const lines = content.split("\n");
747
+ const result = [];
748
+ let inCredentialSection = false;
749
+ for (const line of lines) {
750
+ if (/^\s*\[credential/i.test(line)) {
751
+ inCredentialSection = true;
752
+ result.push("# [REDACTED by pai — credential section removed]");
753
+ continue;
754
+ }
755
+ if (inCredentialSection && /^\s*\[/.test(line)) inCredentialSection = false;
756
+ if (!inCredentialSection) result.push(line);
757
+ }
758
+ return result.join("\n");
759
+ }
760
+ /**
761
+ * Strip IdentityFile lines from SSH config.
762
+ * Keeps Host, HostName, User, Port — removes key paths.
763
+ */
764
+ function stripSshIdentityFiles(content) {
765
+ return content.split("\n").map((line) => {
766
+ if (/^\s*IdentityFile\s/i.test(line)) return " # [REDACTED by pai — IdentityFile removed]";
767
+ return line;
768
+ }).join("\n");
769
+ }
770
+
771
+ //#endregion
772
+ //#region src/connectors/mac/collectors.ts
773
+ /**
774
+ * Mac system context collectors.
775
+ * Each collector gathers a specific category of personalized context
776
+ * from the local macOS environment and returns a CollectorResult.
777
+ */
778
+ /** Run a shell command and return stdout (empty string on failure) */
779
+ async function exec(cmd, args) {
780
+ return new Promise((resolve) => {
781
+ execFile(cmd, args, {
782
+ encoding: "utf-8",
783
+ timeout: 15e3
784
+ }, (err, stdout) => {
785
+ resolve(err ? "" : stdout.trim());
786
+ });
787
+ });
788
+ }
789
+ /** Read a file, return empty string on failure */
790
+ async function readSafe(filePath) {
791
+ try {
792
+ return await fs.readFile(filePath, "utf-8");
793
+ } catch {
794
+ return "";
795
+ }
796
+ }
797
+ /** Check if path exists */
798
+ async function exists(p) {
799
+ try {
800
+ await fs.access(p);
801
+ return true;
802
+ } catch {
803
+ return false;
804
+ }
805
+ }
806
+ /** Run osascript and return output */
807
+ async function osascript(script) {
808
+ return exec("osascript", ["-e", script]);
809
+ }
810
+ /** Run defaults read and return output */
811
+ async function defaultsRead(...args) {
812
+ return exec("defaults", ["read", ...args]);
813
+ }
814
+ const HOME = os.homedir();
815
+ async function collectIdentityProfile() {
816
+ const lines = ["## User Identity"];
817
+ const username = await exec("id", ["-un"]);
818
+ if (username) lines.push(`- macOS Username: ${username}`);
819
+ const nameMatch = (await exec("dscl", [
820
+ ".",
821
+ "-read",
822
+ `/Users/${username}`,
823
+ "RealName"
824
+ ])).split("\n").find((l) => l.trim() && !l.includes("RealName"));
825
+ if (nameMatch?.trim()) lines.push(`- Real Name (local): ${nameMatch.trim()}`);
826
+ const mobileMe = await defaultsRead("MobileMeAccounts");
827
+ const appleIdMatch = mobileMe.match(/AccountID\s*=\s*"?([^";\n]+)/);
828
+ const displayNameMatch = mobileMe.match(/DisplayName\s*=\s*"?([^";\n]+)/);
829
+ if (displayNameMatch) lines.push(`- Apple ID Display Name: ${displayNameMatch[1].trim()}`);
830
+ if (appleIdMatch) lines.push(`- Apple ID: ${appleIdMatch[1].trim()}`);
831
+ const computerName = await exec("scutil", ["--get", "ComputerName"]);
832
+ if (computerName) lines.push(`- Computer Name: ${computerName}`);
833
+ const gitName = await exec("git", [
834
+ "config",
835
+ "--global",
836
+ "user.name"
837
+ ]);
838
+ const gitEmail = await exec("git", [
839
+ "config",
840
+ "--global",
841
+ "user.email"
842
+ ]);
843
+ if (gitName) lines.push(`- Git Name: ${gitName}`);
844
+ if (gitEmail) lines.push(`- Git Email: ${gitEmail}`);
845
+ lines.push("", "## Locale & Language");
846
+ const langList = (await defaultsRead("NSGlobalDomain", "AppleLanguages")).match(/"([^"]+)"/g)?.map((s) => s.replace(/"/g, "")) ?? [];
847
+ if (langList.length > 0) lines.push(`- Languages: ${langList.join(", ")}`);
848
+ const locale = await defaultsRead("NSGlobalDomain", "AppleLocale");
849
+ if (locale) lines.push(`- Locale: ${locale}`);
850
+ const inputSources = await defaultsRead("com.apple.HIToolbox", "AppleSelectedInputSources");
851
+ const inputMethods = inputSources.match(/"Input Mode"\s*=\s*"([^"]+)"/g) ?? [];
852
+ const bundleIds = inputSources.match(/"Bundle ID"\s*=\s*"([^"]+)"/g) ?? [];
853
+ const inputs = [...inputMethods, ...bundleIds].map((s) => s.replace(/.*"([^"]+)"$/, "$1")).filter((s) => !s.includes("PressAndHold"));
854
+ if (inputs.length > 0) lines.push(`- Input Methods: ${inputs.join(", ")}`);
855
+ const appearance = await defaultsRead("-g", "AppleInterfaceStyle");
856
+ lines.push(`- System Appearance: ${appearance || "Light"}`);
857
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
858
+ lines.push(`- Timezone: ${tz}`);
859
+ return {
860
+ id: "identity-profile",
861
+ title: "Mac User Identity Profile",
862
+ content: lines.join("\n")
863
+ };
864
+ }
865
+ async function collectCalendarContext() {
866
+ let calNames = [];
867
+ const raw = await osascript("tell application \"Calendar\" to get name of every calendar");
868
+ if (raw) calNames = raw.split(", ").map((s) => s.trim()).filter(Boolean);
869
+ const lines = [
870
+ "## Calendar Subscriptions",
871
+ "",
872
+ `Total calendars: ${calNames.length}`,
873
+ "",
874
+ ...calNames.map((name) => `- ${name}`)
875
+ ];
876
+ return {
877
+ id: "calendar-context",
878
+ title: "Calendar Context",
879
+ content: calNames.length > 0 ? lines.join("\n") : "No calendar data accessible (Calendar app may not be running)."
880
+ };
881
+ }
882
+ async function collectFileOrganization() {
883
+ const lines = [];
884
+ const docsDir = path.join(HOME, "Documents");
885
+ try {
886
+ const dirs = (await fs.readdir(docsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
887
+ lines.push("## Documents Folders", "", ...dirs.map((d) => `- ${d}`));
888
+ } catch {
889
+ lines.push("## Documents Folders", "", "(not accessible)");
890
+ }
891
+ const desktopDir = path.join(HOME, "Desktop");
892
+ try {
893
+ const entries = await fs.readdir(desktopDir);
894
+ lines.push("", "## Desktop Items", "", ...entries.slice(0, 20).map((e) => `- ${e}`));
895
+ if (entries.length > 20) lines.push(`- ... and ${entries.length - 20} more`);
896
+ } catch {
897
+ lines.push("", "## Desktop Items", "", "(not accessible)");
898
+ }
899
+ const dlDir = path.join(HOME, "Downloads");
900
+ try {
901
+ const entries = await fs.readdir(dlDir);
902
+ lines.push("", "## Recent Downloads (latest 15 by name)", "", ...entries.slice(0, 15).map((e) => `- ${e}`));
903
+ } catch {
904
+ lines.push("", "## Recent Downloads", "", "(not accessible)");
905
+ }
906
+ return {
907
+ id: "file-organization",
908
+ title: "File Organization Structure",
909
+ content: lines.join("\n")
910
+ };
911
+ }
912
+ async function collectDevEnvironment() {
913
+ const lines = ["## Runtime Versions"];
914
+ for (const [name, cmd, args] of [
915
+ [
916
+ "Node.js",
917
+ "node",
918
+ ["--version"]
919
+ ],
920
+ [
921
+ "Python",
922
+ "python3",
923
+ ["--version"]
924
+ ],
925
+ [
926
+ "Go",
927
+ "go",
928
+ ["version"]
929
+ ],
930
+ [
931
+ "Rust",
932
+ "rustc",
933
+ ["--version"]
934
+ ],
935
+ [
936
+ "Java",
937
+ "java",
938
+ ["--version"]
939
+ ]
940
+ ]) {
941
+ const ver = await exec(cmd, args);
942
+ if (ver) {
943
+ const first = ver.split("\n")[0] ?? ver;
944
+ lines.push(`- ${name}: ${first}`);
945
+ }
946
+ }
947
+ lines.push("", "## Package Managers");
948
+ for (const pm of [
949
+ "brew",
950
+ "pnpm",
951
+ "npm",
952
+ "pip3",
953
+ "cargo"
954
+ ]) {
955
+ const which = await exec("which", [pm]);
956
+ if (which) lines.push(`- ${pm}: ${which}`);
957
+ }
958
+ const docker = await exec("docker", ["--version"]);
959
+ if (docker) lines.push("", "## Docker", "", `- ${docker}`);
960
+ lines.push("", "## Shell");
961
+ const shell = process.env.SHELL ?? "";
962
+ lines.push(`- Shell: ${shell}`);
963
+ const zshrc = await readSafe(path.join(HOME, ".zshrc"));
964
+ const themeMatch = zshrc.match(/^ZSH_THEME="?([^"\n]+)/m);
965
+ const pluginsMatch = zshrc.match(/^plugins=\(([^)]+)\)/m);
966
+ if (themeMatch) lines.push(`- Oh-My-Zsh Theme: ${themeMatch[1]}`);
967
+ if (pluginsMatch) lines.push(`- Oh-My-Zsh Plugins: ${pluginsMatch[1].trim()}`);
968
+ const npmGlobal = await exec("npm", [
969
+ "list",
970
+ "-g",
971
+ "--depth=0"
972
+ ]);
973
+ if (npmGlobal) {
974
+ const pkgs = npmGlobal.split("\n").filter((l) => l.startsWith("├") || l.startsWith("└")).map((l) => l.replace(/^[├└─┬│\s]+/, "").trim()).filter(Boolean);
975
+ if (pkgs.length > 0) lines.push("", "## Global npm Packages", "", ...pkgs.map((p) => `- ${p}`));
976
+ }
977
+ const casks = await exec("brew", ["list", "--cask"]);
978
+ if (casks) {
979
+ const caskList = casks.split("\n").filter(Boolean);
980
+ lines.push("", "## Homebrew Casks", "", ...caskList.map((c) => `- ${c}`));
981
+ }
982
+ return {
983
+ id: "dev-environment",
984
+ title: "Development Environment",
985
+ content: lines.join("\n")
986
+ };
987
+ }
988
+ async function collectDevPreferences() {
989
+ const lines = [];
990
+ const zshrc = await readSafe(path.join(HOME, ".zshrc"));
991
+ if (zshrc) {
992
+ const sanitized = stripSecretExports(zshrc);
993
+ const aliases = sanitized.split("\n").filter((l) => l.match(/^\s*alias\s/));
994
+ if (aliases.length > 0) lines.push("## Shell Aliases", "", ...aliases);
995
+ const pathLines = sanitized.split("\n").filter((l) => l.match(/^\s*export\s+PATH/) && !l.includes("REDACTED"));
996
+ if (pathLines.length > 0) lines.push("", "## PATH Additions", "", ...pathLines);
997
+ }
998
+ const gitconfig = await readSafe(path.join(HOME, ".gitconfig"));
999
+ if (gitconfig) {
1000
+ const sanitized = stripGitCredentials(gitconfig);
1001
+ lines.push("", "## Git Config", "", "```", sanitized.trim(), "```");
1002
+ }
1003
+ const gitAliases = await exec("git", [
1004
+ "config",
1005
+ "--global",
1006
+ "--get-regexp",
1007
+ "alias"
1008
+ ]);
1009
+ if (gitAliases) lines.push("", "## Git Aliases", "", ...gitAliases.split("\n").map((l) => `- ${l}`));
1010
+ const defaultBranch = await exec("git", [
1011
+ "config",
1012
+ "--global",
1013
+ "init.defaultBranch"
1014
+ ]);
1015
+ if (defaultBranch) lines.push("", `## Default Git Branch: ${defaultBranch}`);
1016
+ const extDir = path.join(HOME, ".cursor", "extensions");
1017
+ try {
1018
+ const exts = (await fs.readdir(extDir)).filter((e) => !e.startsWith(".") && e !== "extensions.json");
1019
+ if (exts.length > 0) lines.push("", "## Cursor Extensions", "", ...exts.map((e) => `- ${e}`));
1020
+ } catch {}
1021
+ return {
1022
+ id: "dev-preferences",
1023
+ title: "Development Preferences",
1024
+ content: lines.join("\n")
1025
+ };
1026
+ }
1027
+ async function collectShellHabits() {
1028
+ const raw = await readSafe(path.join(HOME, ".zsh_history"));
1029
+ if (!raw) return {
1030
+ id: "shell-habits",
1031
+ title: "Shell Command Habits",
1032
+ content: "No zsh history found."
1033
+ };
1034
+ const rawLines = raw.split("\n");
1035
+ const totalEntries = rawLines.length;
1036
+ const freq = /* @__PURE__ */ new Map();
1037
+ for (const line of rawLines) {
1038
+ const firstWord = line.replace(/^:\s*\d+:\d+;/, "").trim().split(/\s+/)[0];
1039
+ if (firstWord && firstWord.length > 0 && firstWord.length < 50) freq.set(firstWord, (freq.get(firstWord) ?? 0) + 1);
1040
+ }
1041
+ const top30 = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30);
1042
+ return {
1043
+ id: "shell-habits",
1044
+ title: "Shell Command Habits",
1045
+ content: [
1046
+ `Total history entries: ${totalEntries}`,
1047
+ "",
1048
+ "## Most Used Commands (Top 30)",
1049
+ "",
1050
+ ...top30.map(([cmd, count]) => `- ${cmd}: ${count} times`)
1051
+ ].join("\n")
1052
+ };
1053
+ }
1054
+ async function collectCodingRules() {
1055
+ const sections = [];
1056
+ const claudePaths = [path.join(HOME, "CLAUDE.md"), path.join(HOME, ".claude", "CLAUDE.md")];
1057
+ for (const p of claudePaths) {
1058
+ const content = await readSafe(p);
1059
+ if (content) {
1060
+ const relPath = p.replace(HOME, "~");
1061
+ sections.push(`## ${relPath}`, "", content.trim(), "");
1062
+ }
1063
+ }
1064
+ const cmdDir = path.join(HOME, ".claude", "commands");
1065
+ try {
1066
+ const mdFiles = (await fs.readdir(cmdDir)).filter((e) => e.endsWith(".md"));
1067
+ if (mdFiles.length > 0) {
1068
+ sections.push("## Claude Custom Commands", "");
1069
+ for (const f of mdFiles) {
1070
+ const content = await readSafe(path.join(cmdDir, f));
1071
+ if (content) sections.push(`### ${f}`, "", content.trim(), "");
1072
+ }
1073
+ }
1074
+ } catch {}
1075
+ const activeRepos = await findRecentGitRepos(30);
1076
+ const projectRules = [];
1077
+ for (const repo of activeRepos.slice(0, 10)) for (const ruleFile of [
1078
+ "CLAUDE.md",
1079
+ "AGENTS.md",
1080
+ ".cursorrules"
1081
+ ]) {
1082
+ const content = await readSafe(path.join(repo, ruleFile));
1083
+ if (content && content.length > 10) {
1084
+ const repoName = path.basename(repo);
1085
+ projectRules.push(`### ${repoName}/${ruleFile}`, "", content.trim().slice(0, 2e3), "");
1086
+ }
1087
+ }
1088
+ if (projectRules.length > 0) sections.push("## Project-Level Rules", "", ...projectRules);
1089
+ const cursorRulesDir = path.join(HOME, ".cursor", "rules");
1090
+ try {
1091
+ const mdFiles = (await fs.readdir(cursorRulesDir)).filter((e) => e.endsWith(".md") || e.endsWith(".mdc"));
1092
+ if (mdFiles.length > 0) {
1093
+ sections.push("## Global Cursor Rules", "");
1094
+ for (const f of mdFiles) {
1095
+ const content = await readSafe(path.join(cursorRulesDir, f));
1096
+ if (content) sections.push(`### ${f}`, "", content.trim().slice(0, 1e3), "");
1097
+ }
1098
+ }
1099
+ } catch {}
1100
+ return {
1101
+ id: "coding-rules",
1102
+ title: "Coding Rules and AI Agent Config",
1103
+ content: sections.length > 0 ? sections.join("\n") : "No coding rules found."
1104
+ };
1105
+ }
1106
+ async function collectActiveProjects() {
1107
+ const lines = [];
1108
+ const repos = await findRecentGitRepos(30);
1109
+ if (repos.length > 0) {
1110
+ lines.push("## Active Git Repositories (last 30 days)", "");
1111
+ for (const repo of repos) {
1112
+ const name = repo.replace(HOME, "~");
1113
+ lines.push(`- ${name}`);
1114
+ }
1115
+ }
1116
+ const sshConfig = await readSafe(path.join(HOME, ".ssh", "config"));
1117
+ if (sshConfig) {
1118
+ const hosts = stripSshIdentityFiles(sshConfig).split("\n").filter((l) => /^\s*Host\s/i.test(l) && !l.includes("*")).map((l) => l.replace(/^\s*Host\s+/i, "").trim());
1119
+ if (hosts.length > 0) lines.push("", "## SSH Hosts", "", ...hosts.map((h) => `- ${h}`));
1120
+ }
1121
+ const cloudDir = path.join(HOME, "Library", "CloudStorage");
1122
+ try {
1123
+ const entries = await fs.readdir(cloudDir);
1124
+ if (entries.length > 0) lines.push("", "## Cloud Storage", "", ...entries.map((e) => `- ${e}`));
1125
+ } catch {}
1126
+ return {
1127
+ id: "active-projects",
1128
+ title: "Active Projects and Infrastructure",
1129
+ content: lines.join("\n")
1130
+ };
1131
+ }
1132
+ async function collectProductivitySetup() {
1133
+ const lines = [];
1134
+ try {
1135
+ const apps = (await fs.readdir("/Applications")).filter((e) => e.endsWith(".app")).map((e) => e.replace(".app", ""));
1136
+ lines.push("## Installed Applications", "", ...apps.map((a) => `- ${a}`));
1137
+ } catch {
1138
+ lines.push("## Installed Applications", "", "(not accessible)");
1139
+ }
1140
+ const dockApps = await defaultsRead("com.apple.dock", "persistent-apps");
1141
+ if (dockApps) {
1142
+ const labels = [...dockApps.matchAll(/"file-label"\s*=\s*"?([^";\n}]+)/g)].map((m) => m[1].trim()).filter(Boolean);
1143
+ if (labels.length > 0) lines.push("", "## Dock Apps (pinned)", "", ...labels.map((a) => `- ${a}`));
1144
+ }
1145
+ const browserMatch = (await defaultsRead("com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers")).match(/LSHandlerRoleAll\s*=\s*"([^"]+)"/);
1146
+ if (browserMatch) {
1147
+ const bundleId = browserMatch[1];
1148
+ const browserName = bundleId.includes("edge") ? "Microsoft Edge" : bundleId.includes("chrome") ? "Google Chrome" : bundleId.includes("firefox") ? "Firefox" : bundleId.includes("safari") ? "Safari" : bundleId;
1149
+ lines.push("", `## Default Browser: ${browserName}`);
1150
+ }
1151
+ const resolutions = [...(await exec("system_profiler", ["SPDisplaysDataType"])).matchAll(/Resolution:\s*(.+)/g)].map((m) => m[1].trim());
1152
+ if (resolutions.length > 0) lines.push("", "## Screen Resolution", "", ...resolutions.map((r) => `- ${r}`));
1153
+ return {
1154
+ id: "productivity-setup",
1155
+ title: "Productivity Setup",
1156
+ content: lines.join("\n")
1157
+ };
1158
+ }
1159
+ async function collectBrowserBookmarks() {
1160
+ const raw = await readSafe(path.join(HOME, "Library", "Application Support", "Microsoft Edge", "Default", "Bookmarks"));
1161
+ if (!raw) return {
1162
+ id: "browser-bookmarks",
1163
+ title: "Browser Bookmark Structure",
1164
+ content: "No Edge bookmarks found."
1165
+ };
1166
+ try {
1167
+ const data = JSON.parse(raw);
1168
+ const folders = [];
1169
+ let totalUrls = 0;
1170
+ function walk(node, depth) {
1171
+ if (node.type === "folder") {
1172
+ const children = node.children ?? [];
1173
+ const name = node.name ?? "";
1174
+ if (children.length > 0) folders.push({
1175
+ name,
1176
+ count: children.length,
1177
+ depth
1178
+ });
1179
+ for (const child of children) walk(child, depth + 1);
1180
+ } else if (node.type === "url") totalUrls++;
1181
+ }
1182
+ const roots = data.roots;
1183
+ for (const root of Object.values(roots)) if (root && typeof root === "object" && "children" in root) walk(root, 0);
1184
+ folders.sort((a, b) => b.count - a.count);
1185
+ return {
1186
+ id: "browser-bookmarks",
1187
+ title: "Browser Bookmark Structure",
1188
+ content: [
1189
+ `Total bookmarks: ${totalUrls}`,
1190
+ `Total folders: ${folders.length}`,
1191
+ "",
1192
+ "## Top Bookmark Folders (by item count)",
1193
+ "",
1194
+ ...folders.slice(0, 40).map((f) => `- ${" ".repeat(Math.min(f.depth, 2))}${f.name}: ${f.count} items`)
1195
+ ].join("\n")
1196
+ };
1197
+ } catch {
1198
+ return {
1199
+ id: "browser-bookmarks",
1200
+ title: "Browser Bookmark Structure",
1201
+ content: "Failed to parse Edge bookmarks."
1202
+ };
1203
+ }
1204
+ }
1205
+ async function collectBrowserDomains() {
1206
+ const histPath = path.join(HOME, "Library", "Application Support", "Microsoft Edge", "Default", "History");
1207
+ if (!await exists(histPath)) return {
1208
+ id: "browser-domains",
1209
+ title: "Browser Top Domains (30 days)",
1210
+ content: "No Edge history found."
1211
+ };
1212
+ const tmpDb = path.join(os.tmpdir(), `pai-edge-history-${Date.now()}.db`);
1213
+ try {
1214
+ await fs.copyFile(histPath, tmpDb);
1215
+ const output = await exec("sqlite3", [tmpDb, `
1216
+ SELECT
1217
+ REPLACE(REPLACE(SUBSTR(url, 1, INSTR(SUBSTR(url, 9), '/') + 8), 'https://', ''), 'http://', '') as domain,
1218
+ COUNT(*) as visits
1219
+ FROM urls
1220
+ WHERE last_visit_time > (strftime('%s', 'now', '-30 days') + 11644473600) * 1000000
1221
+ GROUP BY domain
1222
+ ORDER BY visits DESC
1223
+ LIMIT 30;
1224
+ `.trim()]);
1225
+ if (!output) return {
1226
+ id: "browser-domains",
1227
+ title: "Browser Top Domains (30 days)",
1228
+ content: "Could not query Edge history (DB may be locked)."
1229
+ };
1230
+ return {
1231
+ id: "browser-domains",
1232
+ title: "Browser Top Domains (30 days)",
1233
+ content: [
1234
+ "## Top 30 Domains (last 30 days)",
1235
+ "",
1236
+ ...output.split("\n").map((line) => {
1237
+ const [domain, visits] = line.split("|");
1238
+ return `- ${domain}: ${visits} visits`;
1239
+ })
1240
+ ].join("\n")
1241
+ };
1242
+ } finally {
1243
+ try {
1244
+ await fs.unlink(tmpDb);
1245
+ } catch {}
1246
+ }
1247
+ }
1248
+ async function collectGitHubProfile() {
1249
+ const lines = [];
1250
+ if (!await exec("gh", ["--version"])) return {
1251
+ id: "github-profile",
1252
+ title: "GitHub Profile",
1253
+ content: "GitHub CLI (gh) not installed."
1254
+ };
1255
+ if (!await exec("gh", ["auth", "status"])) return {
1256
+ id: "github-profile",
1257
+ title: "GitHub Profile",
1258
+ content: "GitHub CLI not authenticated. Run `gh auth login` to enable."
1259
+ };
1260
+ const userJson = await exec("gh", [
1261
+ "api",
1262
+ "user",
1263
+ "--jq",
1264
+ "[.login, .name, .bio, .company, .location, .blog, .public_repos, .followers, .following, .created_at] | @tsv"
1265
+ ]);
1266
+ if (userJson) {
1267
+ const [login, name, bio, company, location, blog, publicRepos, followers, following, createdAt] = userJson.split(" ");
1268
+ lines.push("## GitHub Profile", "");
1269
+ if (login) lines.push(`- Username: ${login}`);
1270
+ if (name) lines.push(`- Name: ${name}`);
1271
+ if (bio) lines.push(`- Bio: ${bio}`);
1272
+ if (company) lines.push(`- Company: ${company}`);
1273
+ if (location) lines.push(`- Location: ${location}`);
1274
+ if (blog) lines.push(`- Website: ${blog}`);
1275
+ if (publicRepos) lines.push(`- Public Repos: ${publicRepos}`);
1276
+ if (followers || following) lines.push(`- Followers/Following: ${followers}/${following}`);
1277
+ if (createdAt) lines.push(`- Member since: ${createdAt}`);
1278
+ }
1279
+ const reposJson = await exec("gh", [
1280
+ "repo",
1281
+ "list",
1282
+ "--limit",
1283
+ "10",
1284
+ "--sort",
1285
+ "updated",
1286
+ "--json",
1287
+ "name,description,primaryLanguage,pushedAt,isPrivate",
1288
+ "--jq",
1289
+ ".[] | [.name, .description, (.primaryLanguage.name // \"\"), .pushedAt, .isPrivate] | @tsv"
1290
+ ]);
1291
+ if (reposJson) {
1292
+ const repos = reposJson.split("\n").filter(Boolean);
1293
+ if (repos.length > 0) {
1294
+ lines.push("", "## Recent GitHub Repos (by push date)", "");
1295
+ for (const repo of repos) {
1296
+ const [name, desc, lang, _pushed, isPrivate] = repo.split(" ");
1297
+ const visibility = isPrivate === "true" ? "private" : "public";
1298
+ const langTag = lang ? ` [${lang}]` : "";
1299
+ const descTag = desc ? ` — ${desc}` : "";
1300
+ lines.push(`- ${name}${langTag} (${visibility})${descTag}`);
1301
+ }
1302
+ }
1303
+ }
1304
+ const starsJson = await exec("gh", [
1305
+ "api",
1306
+ "user/starred?per_page=20&sort=created&direction=desc",
1307
+ "--jq",
1308
+ ".[].full_name"
1309
+ ]);
1310
+ if (starsJson) {
1311
+ const stars = starsJson.split("\n").filter(Boolean);
1312
+ if (stars.length > 0) {
1313
+ lines.push("", "## Recently Starred Repos (interest signals)", "");
1314
+ lines.push(stars.map((s) => `- ${s}`).join("\n"));
1315
+ }
1316
+ }
1317
+ return {
1318
+ id: "github-profile",
1319
+ title: "GitHub Profile & Activity",
1320
+ content: lines.length > 0 ? lines.join("\n") : "No GitHub data accessible."
1321
+ };
1322
+ }
1323
+ async function collectRecentFocus() {
1324
+ const lines = [];
1325
+ const repos = await findRecentGitRepos(14);
1326
+ const commitTopics = /* @__PURE__ */ new Map();
1327
+ const recentMessages = [];
1328
+ for (const repo of repos.slice(0, 8)) {
1329
+ const log = await exec("git", [
1330
+ "-C",
1331
+ repo,
1332
+ "log",
1333
+ "--oneline",
1334
+ "--since=14 days ago",
1335
+ "--format=%s",
1336
+ "-n",
1337
+ "20"
1338
+ ]);
1339
+ if (log) for (const msg of log.split("\n").filter(Boolean)) {
1340
+ recentMessages.push(msg);
1341
+ const type = msg.match(/^(feat|fix|refactor|test|docs|chore|perf|ci|build|style)/i);
1342
+ if (type) {
1343
+ const key = type[1].toLowerCase();
1344
+ commitTopics.set(key, (commitTopics.get(key) ?? 0) + 1);
1345
+ }
1346
+ }
1347
+ }
1348
+ if (commitTopics.size > 0) {
1349
+ const sorted = [...commitTopics.entries()].sort((a, b) => b[1] - a[1]);
1350
+ lines.push("## Recent Commit Activity (last 2 weeks)", "");
1351
+ lines.push(`Total commits analyzed: ${recentMessages.length}`);
1352
+ lines.push("", "Commit types:");
1353
+ for (const [type, count] of sorted) lines.push(`- ${type}: ${count}`);
1354
+ }
1355
+ if (recentMessages.length > 0) {
1356
+ lines.push("", "## Recent Commit Messages (sample)", "");
1357
+ const unique = [...new Set(recentMessages)].slice(0, 15);
1358
+ for (const msg of unique) lines.push(`- ${msg}`);
1359
+ }
1360
+ const histRaw = await readSafe(path.join(HOME, ".zsh_history"));
1361
+ if (histRaw) {
1362
+ const recent = histRaw.split("\n").slice(-200);
1363
+ const cdPaths = /* @__PURE__ */ new Map();
1364
+ const tools = /* @__PURE__ */ new Map();
1365
+ for (const line of recent) {
1366
+ const cmd = line.replace(/^:\s*\d+:\d+;/, "").trim();
1367
+ const cdMatch = cmd.match(/^cd\s+(.+)/);
1368
+ if (cdMatch) {
1369
+ const dest = cdMatch[1].trim().replace(/^~/, HOME);
1370
+ cdPaths.set(dest, (cdPaths.get(dest) ?? 0) + 1);
1371
+ }
1372
+ const toolMatch = cmd.match(/^(docker|kubectl|terraform|aws|gcloud|firebase|vercel|netlify|npm|pnpm|yarn|bun|cargo|go|python|pip|uv)\b/);
1373
+ if (toolMatch) tools.set(toolMatch[1], (tools.get(toolMatch[1]) ?? 0) + 1);
1374
+ }
1375
+ if (cdPaths.size > 0) {
1376
+ const topDirs = [...cdPaths.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
1377
+ lines.push("", "## Recent Working Directories", "");
1378
+ for (const [dir, count] of topDirs) lines.push(`- ${dir.replace(HOME, "~")}: ${count}x`);
1379
+ }
1380
+ if (tools.size > 0) {
1381
+ const topTools = [...tools.entries()].sort((a, b) => b[1] - a[1]);
1382
+ lines.push("", "## Recently Used Tools (from history)", "");
1383
+ for (const [tool, count] of topTools) lines.push(`- ${tool}: ${count}x`);
1384
+ }
1385
+ }
1386
+ return {
1387
+ id: "recent-focus",
1388
+ title: "Recent Focus & Activity",
1389
+ content: lines.length > 0 ? lines.join("\n") : "No recent activity data found."
1390
+ };
1391
+ }
1392
+ async function collectSocialProfiles() {
1393
+ const lines = [];
1394
+ const npmrc = await readSafe(path.join(HOME, ".npmrc"));
1395
+ if (npmrc) {
1396
+ const registryMatch = npmrc.match(/^registry\s*=\s*(.+)/m);
1397
+ if (registryMatch) lines.push(`- npm registry: ${registryMatch[1].trim()}`);
1398
+ const scopes = npmrc.match(/^@[\w-]+:registry/gm);
1399
+ if (scopes) for (const s of scopes) lines.push(`- npm scope: ${s.split(":")[0]}`);
1400
+ }
1401
+ const npmUser = await exec("npm", ["whoami"]);
1402
+ if (npmUser) lines.push(`- npm username: ${npmUser}`);
1403
+ const pypirc = await readSafe(path.join(HOME, ".pypirc"));
1404
+ if (pypirc) {
1405
+ const repoMatch = pypirc.match(/repository\s*=\s*(.+)/);
1406
+ if (repoMatch) lines.push(`- PyPI repository: ${repoMatch[1].trim()}`);
1407
+ const usernameMatch = pypirc.match(/username\s*=\s*(.+)/);
1408
+ if (usernameMatch) lines.push(`- PyPI username: ${usernameMatch[1].trim()}`);
1409
+ }
1410
+ const dockerConfig = await readSafe(path.join(HOME, ".docker", "config.json"));
1411
+ if (dockerConfig) try {
1412
+ const cfg = JSON.parse(dockerConfig);
1413
+ const auths = Object.keys(cfg.auths ?? {});
1414
+ if (auths.length > 0) lines.push(`- Docker registries: ${auths.join(", ")}`);
1415
+ } catch {}
1416
+ const cargoConfig = await readSafe(path.join(HOME, ".cargo", "config.toml"));
1417
+ if (cargoConfig) {
1418
+ const registries = cargoConfig.match(/\[registries\.(\w+)\]/g);
1419
+ if (registries) lines.push(`- Cargo registries: ${registries.map((r) => r.replace(/\[registries\.|\]/g, "")).join(", ")}`);
1420
+ }
1421
+ const awsIdentity = await exec("aws", [
1422
+ "sts",
1423
+ "get-caller-identity",
1424
+ "--query",
1425
+ "Account",
1426
+ "--output",
1427
+ "text"
1428
+ ]);
1429
+ if (awsIdentity) lines.push(`- AWS Account: ${awsIdentity}`);
1430
+ const gcpProject = await exec("gcloud", [
1431
+ "config",
1432
+ "get-value",
1433
+ "project"
1434
+ ]);
1435
+ if (gcpProject && !gcpProject.includes("unset")) lines.push(`- GCP Project: ${gcpProject}`);
1436
+ const vercelUser = await exec("vercel", ["whoami"]);
1437
+ if (vercelUser) lines.push(`- Vercel: ${vercelUser}`);
1438
+ return {
1439
+ id: "social-profiles",
1440
+ title: "Registry & Cloud Profiles",
1441
+ content: lines.length > 0 ? [
1442
+ "## Registry & Cloud Accounts",
1443
+ "",
1444
+ ...lines
1445
+ ].join("\n") : "No registry or cloud profiles detected."
1446
+ };
1447
+ }
1448
+ /** Find git repos under ~/Documents modified in the last N days */
1449
+ async function findRecentGitRepos(days) {
1450
+ const output = await exec("find", [
1451
+ path.join(HOME, "Documents"),
1452
+ "-maxdepth",
1453
+ "4",
1454
+ "-name",
1455
+ ".git",
1456
+ "-type",
1457
+ "d",
1458
+ "-mtime",
1459
+ `-${days}`
1460
+ ]);
1461
+ if (!output) return [];
1462
+ return output.split("\n").filter(Boolean).map((p) => p.replace(/\/.git$/, ""));
1463
+ }
1464
+ /** All available Mac collectors in execution order */
1465
+ const ALL_COLLECTORS = [
1466
+ collectIdentityProfile,
1467
+ collectCalendarContext,
1468
+ collectFileOrganization,
1469
+ collectDevEnvironment,
1470
+ collectDevPreferences,
1471
+ collectShellHabits,
1472
+ collectCodingRules,
1473
+ collectActiveProjects,
1474
+ collectProductivitySetup,
1475
+ collectBrowserBookmarks,
1476
+ collectBrowserDomains,
1477
+ collectGitHubProfile,
1478
+ collectRecentFocus,
1479
+ collectSocialProfiles
1480
+ ];
1481
+
1482
+ //#endregion
1483
+ //#region src/profile/index.ts
1484
+ /**
1485
+ * Profile module — compile, load, and rebuild user profiles.
1486
+ */
1487
+ /** Load the current profile from disk. Returns null if not found. */
1488
+ async function loadProfile() {
1489
+ try {
1490
+ return await fs.readFile(getProfilePath(), "utf-8");
1491
+ } catch {
1492
+ return null;
1493
+ }
1494
+ }
1495
+ /** Save profile markdown to disk. */
1496
+ async function saveProfile(content) {
1497
+ const profilePath = getProfilePath();
1498
+ await fs.writeFile(profilePath, content, "utf-8");
1499
+ return profilePath;
1500
+ }
1501
+ /**
1502
+ * Full rebuild: scan local machine → compile profile → write to disk.
1503
+ * Also writes raw files for the scan data (for vault/distill pipeline compatibility).
1504
+ */
1505
+ async function rebuildProfile(opts) {
1506
+ const verbose = opts?.verbose ?? true;
1507
+ const spin = verbose ? spinner("Scanning local machine...") : null;
1508
+ const settled = await Promise.allSettled(ALL_COLLECTORS.map((collector) => collector()));
1509
+ const results = [];
1510
+ const failed = [];
1511
+ for (let i = 0; i < settled.length; i++) {
1512
+ const s = settled[i];
1513
+ if (s.status === "fulfilled") results.push(s.value);
1514
+ else {
1515
+ const name = ALL_COLLECTORS[i].name;
1516
+ failed.push(name);
1517
+ if (verbose) warn(`Collector ${name} failed: ${String(s.reason)}`);
1518
+ }
1519
+ }
1520
+ spin?.succeed(`Scanned ${results.length} sources (${failed.length} failed)`);
1521
+ if (!opts?.skipRaw) for (const entry of results) try {
1522
+ await addConnectorEntry("mac", entry);
1523
+ } catch {}
1524
+ return {
1525
+ profilePath: await saveProfile(compileProfile(results)),
1526
+ results
1527
+ };
1528
+ }
1529
+
1530
+ //#endregion
1531
+ //#region src/cli/register.init.ts
1532
+ /** Create directory structure, write default configs, register QMD, scan & compile profile. */
1533
+ async function runInit(options = {}) {
1534
+ const { overwriteConfig = false, skipScan = false } = options;
1535
+ const paiHome = getPaiHome();
1536
+ const dirs = [
1537
+ path.join(getRawDir(), "local"),
1538
+ path.join(getRawDir(), "web"),
1539
+ path.join(getRawDir(), "connector"),
1540
+ path.join(getVaultDir(), "coding"),
1541
+ path.join(getVaultDir(), "work"),
1542
+ path.join(getVaultDir(), "life"),
1543
+ path.join(getVaultDir(), "preferences"),
1544
+ path.join(getVaultDir(), "context"),
1545
+ getSkillsDir(),
1546
+ getConfigDir()
1547
+ ];
1548
+ for (const dir of dirs) await fs.mkdir(dir, { recursive: true });
1549
+ const configPath = getConfigPath();
1550
+ const profilesPath = getProfilesPath();
1551
+ const prefsPath = getPreferencesPath();
1552
+ if (overwriteConfig) {
1553
+ await saveConfig(configPath, DEFAULT_CONFIG);
1554
+ await saveConfig(profilesPath, DEFAULT_PROFILES);
1555
+ await saveConfig(prefsPath, DEFAULT_PREFERENCES);
1556
+ } else {
1557
+ try {
1558
+ await fs.access(configPath);
1559
+ info("Config already exists, skipping.");
1560
+ } catch {
1561
+ await saveConfig(configPath, DEFAULT_CONFIG);
1562
+ }
1563
+ try {
1564
+ await fs.access(profilesPath);
1565
+ info("Profiles config already exists, skipping.");
1566
+ } catch {
1567
+ await saveConfig(profilesPath, DEFAULT_PROFILES);
1568
+ }
1569
+ try {
1570
+ await fs.access(prefsPath);
1571
+ info("Preferences already exists, skipping.");
1572
+ } catch {
1573
+ await saveConfig(prefsPath, DEFAULT_PREFERENCES);
1574
+ success("Created preferences.md — edit to customize AI behavior.");
1575
+ }
1576
+ }
1577
+ if (await isQmdAvailable()) {
1578
+ try {
1579
+ await execQmd([
1580
+ "collection",
1581
+ "add",
1582
+ getRawDir(),
1583
+ "--name",
1584
+ "raw",
1585
+ "--mask",
1586
+ "**/*.md"
1587
+ ]);
1588
+ success("QMD collection 'raw' registered.");
1589
+ } catch {
1590
+ warn("QMD collection 'raw' may already exist, skipping.");
1591
+ }
1592
+ try {
1593
+ await execQmd([
1594
+ "collection",
1595
+ "add",
1596
+ getVaultDir(),
1597
+ "--name",
1598
+ "vault",
1599
+ "--mask",
1600
+ "**/*.md"
1601
+ ]);
1602
+ success("QMD collection 'vault' registered.");
1603
+ } catch {
1604
+ warn("QMD collection 'vault' may already exist, skipping.");
1605
+ }
1606
+ } else {
1607
+ warn("QMD not found. Install with: npm install -g https://github.com/tobi/qmd");
1608
+ warn("Search features won't work until QMD is installed.");
1609
+ }
1610
+ if (!skipScan) {
1611
+ log("");
1612
+ const { profilePath, results } = await rebuildProfile({ verbose: true });
1613
+ success(`Profile compiled → ${profilePath}`);
1614
+ const sectionCount = results.length;
1615
+ log("");
1616
+ info(`Profile built from ${sectionCount} data sources:`);
1617
+ for (const r of results) {
1618
+ const lineCount = r.content.split("\n").length;
1619
+ log(` ${bold(r.id)} — ${r.title} (${lineCount} lines)`);
1620
+ }
1621
+ }
1622
+ log("");
1623
+ info("Directory structure:");
1624
+ log(` ${paiHome}/profile.md — ${skipScan ? "(skipped scan)" : "your personal profile"}`);
1625
+ log(` ${paiHome}/raw/ — raw data input`);
1626
+ log(` ${paiHome}/vault/ — distilled knowledge`);
1627
+ log(` ${paiHome}/config/ — configuration`);
1628
+ log("");
1629
+ if (skipScan) info("Run \"pai profile --rebuild\" to scan and build your profile.");
1630
+ else {
1631
+ info("Run \"pai distribute\" to deploy your profile to Cursor/agents.");
1632
+ info("Run \"pai profile\" to view your profile anytime.");
1633
+ }
1634
+ }
1635
+ function registerInitCommand(program) {
1636
+ program.command("init").description("Initialize pai: create dirs, scan local machine, compile profile").option("--skip-scan", "Skip local machine scan (for CI/testing)").action(async (opts) => {
1637
+ const paiHome = getPaiHome();
1638
+ const spin = spinner(`Initializing pai at ${paiHome}...`);
1639
+ try {
1640
+ spin.succeed(`Initializing pai at ${paiHome}`);
1641
+ await runInit({
1642
+ overwriteConfig: false,
1643
+ skipScan: opts.skipScan
1644
+ });
1645
+ } catch (err) {
1646
+ spin.fail("Initialization failed");
1647
+ const msg = err instanceof Error ? err.message : String(err);
1648
+ error(msg);
1649
+ process.exit(1);
1650
+ }
1651
+ });
1652
+ }
1653
+
1654
+ //#endregion
1655
+ //#region src/scraper/index.ts
1656
+ /**
1657
+ * Scrape a URL and return title + markdown content.
1658
+ * Uses fetch + defuddle for content extraction.
1659
+ * Falls back to basic HTML extraction if defuddle is unavailable.
1660
+ */
1661
+ async function scrapeUrl(url, timeout = 3e4) {
1662
+ const controller = new AbortController();
1663
+ const timer = setTimeout(() => controller.abort(), timeout);
1664
+ let html;
1665
+ try {
1666
+ const response = await fetch(url, {
1667
+ signal: controller.signal,
1668
+ headers: {
1669
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
1670
+ Accept: "text/html,application/xhtml+xml"
1671
+ }
1672
+ });
1673
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
1674
+ html = await response.text();
1675
+ } finally {
1676
+ clearTimeout(timer);
1677
+ }
1678
+ try {
1679
+ const defuddleMod = await import("defuddle/node");
1680
+ const Defuddle = defuddleMod.Defuddle ?? defuddleMod.default;
1681
+ if (Defuddle) {
1682
+ const result = new Defuddle(html, { url }).parse();
1683
+ return {
1684
+ url,
1685
+ title: result.title || extractTitleFromHtml(html),
1686
+ markdown: result.content ? htmlToSimpleMarkdown(result.content) : extractTextFromHtml(html)
1687
+ };
1688
+ }
1689
+ } catch {
1690
+ warn("defuddle not available, using basic HTML extraction");
1691
+ }
1692
+ return {
1693
+ url,
1694
+ title: extractTitleFromHtml(html),
1695
+ markdown: extractTextFromHtml(html)
1696
+ };
1697
+ }
1698
+ /** Extract <title> from HTML */
1699
+ function extractTitleFromHtml(html) {
1700
+ return html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ?? "Untitled";
1701
+ }
1702
+ /** Basic HTML to text extraction (fallback) */
1703
+ function extractTextFromHtml(html) {
1704
+ return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim().slice(0, 1e4);
1705
+ }
1706
+ /** Convert simple HTML to markdown */
1707
+ function htmlToSimpleMarkdown(html) {
1708
+ return html.replace(/<h1[^>]*>([\s\S]*?)<\/h1>/gi, "# $1\n\n").replace(/<h2[^>]*>([\s\S]*?)<\/h2>/gi, "## $1\n\n").replace(/<h3[^>]*>([\s\S]*?)<\/h3>/gi, "### $1\n\n").replace(/<p[^>]*>([\s\S]*?)<\/p>/gi, "$1\n\n").replace(/<br\s*\/?>/gi, "\n").replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, "**$1**").replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, "*$1*").replace(/<a[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)").replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, "- $1\n").replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, "`$1`").replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, "```\n$1\n```\n").replace(/<[^>]+>/g, "").replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, "\"").replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/\n{3,}/g, "\n\n").trim();
1709
+ }
1710
+
1711
+ //#endregion
1712
+ //#region src/cli/register.add.ts
1713
+ function registerAddCommand(program) {
1714
+ program.command("add <input>").description("Add content to raw layer (text, file path, or URL with --url)").option("--url", "Treat input as URL, scrape and save").option("--source <source>", "Source tag", "local").action(async (input, opts) => {
1715
+ try {
1716
+ let filePath;
1717
+ if (opts.url) {
1718
+ const spin = spinner(`Scraping ${input}...`);
1719
+ try {
1720
+ const result = await scrapeUrl(input, (await loadConfig()).scraper.timeout);
1721
+ filePath = await addUrl(input, result.title, result.markdown);
1722
+ spin.succeed(`Scraped: ${result.title}`);
1723
+ } catch (err) {
1724
+ spin.fail("Scraping failed");
1725
+ throw err;
1726
+ }
1727
+ } else try {
1728
+ if ((await fs.stat(input)).isFile()) {
1729
+ filePath = await addFile(input, opts.source);
1730
+ success(`Added file: ${input}`);
1731
+ } else throw new Error("Not a file");
1732
+ } catch {
1733
+ filePath = await addText(input, opts.source);
1734
+ success("Added text content");
1735
+ }
1736
+ info(`Saved to: ${filePath}`);
1737
+ info("Run \"pai distill\" to process into vault.");
1738
+ } catch (err) {
1739
+ const msg = err instanceof Error ? err.message : String(err);
1740
+ error(`Failed to add: ${msg}`);
1741
+ process.exit(1);
1742
+ }
1743
+ });
1744
+ }
1745
+
1746
+ //#endregion
1747
+ //#region src/llm/client.ts
1748
+ let cachedConfig = null;
1749
+ let cachedPreferences = null;
1750
+ async function getConfig() {
1751
+ if (!cachedConfig) cachedConfig = await loadConfig();
1752
+ return cachedConfig;
1753
+ }
1754
+ /** Load user preferences.md (cached per process) */
1755
+ async function getPreferences() {
1756
+ if (cachedPreferences !== null) return cachedPreferences;
1757
+ try {
1758
+ cachedPreferences = await fs.readFile(getPreferencesPath(), "utf-8");
1759
+ } catch {
1760
+ cachedPreferences = "";
1761
+ }
1762
+ return cachedPreferences;
1763
+ }
1764
+ /** Create an OpenAI-compatible client from config */
1765
+ async function createLlmClient() {
1766
+ const config = await getConfig();
1767
+ const apiKey = process.env[config.llm.apiKeyEnv];
1768
+ if (!apiKey) throw new Error(`Missing API key: set ${config.llm.apiKeyEnv} environment variable`);
1769
+ return new OpenAI({
1770
+ apiKey,
1771
+ baseURL: config.llm.baseUrl || void 0
1772
+ });
1773
+ }
1774
+ /**
1775
+ * Single LLM call with system + user messages.
1776
+ * Automatically prepends user preferences to the system prompt.
1777
+ * Retries once on failure.
1778
+ */
1779
+ async function llmCall(prompt, system, model) {
1780
+ const config = await getConfig();
1781
+ const client = await createLlmClient();
1782
+ const modelName = model ?? config.llm.cheapModel;
1783
+ const prefs = await getPreferences();
1784
+ const fullSystem = prefs ? `${system}\n\n---USER PREFERENCES (always respect these)---\n${prefs}` : system;
1785
+ for (let attempt = 0; attempt < 2; attempt++) try {
1786
+ return (await client.chat.completions.create({
1787
+ model: modelName,
1788
+ messages: [{
1789
+ role: "system",
1790
+ content: fullSystem
1791
+ }, {
1792
+ role: "user",
1793
+ content: prompt
1794
+ }],
1795
+ temperature: .3
1796
+ })).choices[0]?.message?.content ?? "";
1797
+ } catch (err) {
1798
+ if (attempt === 0) {
1799
+ await new Promise((r) => setTimeout(r, 1e3));
1800
+ continue;
1801
+ }
1802
+ throw err;
1803
+ }
1804
+ return "";
1805
+ }
1806
+
1807
+ //#endregion
1808
+ //#region src/prompts/triage.ts
1809
+ /** Build system prompt for the triage step */
1810
+ function triageSystemPrompt() {
1811
+ return `You are a personal knowledge management assistant.
1812
+ Your job is to evaluate raw input and extract ALL distinct pieces of personal context worth preserving.
1813
+
1814
+ CRITICAL: One raw input often contains MULTIPLE types of data. You MUST split them into separate entries routed to different vault files.
1815
+
1816
+ Two kinds of valuable content:
1817
+
1818
+ 1) EXPERIENTIAL: concrete lessons, preferences, tips, or experiences.
1819
+ 2) SYSTEM CONTEXT: data from system/connector scans — identity, environment, habits, preferences.
1820
+
1821
+ Routing targets:
1822
+ - vault/context/identity.md — name, email, role, languages, locale, timezone
1823
+ - vault/context/active-projects.md — repos, cloud infra, SSH hosts
1824
+ - vault/preferences/tools.md — tech stack, IDEs, runtimes, package managers
1825
+ - vault/preferences/workflow.md — shell habits, git config, aliases, dev workflow
1826
+ - vault/life/interests.md — interests, domains of focus, bookmarks, browsing patterns
1827
+ - vault/life/lifestyle.md — calendar, apps, music, media, daily routines
1828
+ - vault/coding/*.md — coding lessons (use existing or suggest new)
1829
+ - vault/work/*.md — work-related lessons and context
1830
+
1831
+ Do NOT mark as valuable: generic news, ads, or content with no personal signal.
1832
+
1833
+ Respond ONLY with valid JSON (no markdown fences):
1834
+ {
1835
+ "valuable": true/false,
1836
+ "entries": [
1837
+ { "targetFile": "vault/context/identity.md", "extract": "name, email, role, languages" },
1838
+ { "targetFile": "vault/preferences/tools.md", "extract": "IDE, runtimes, package managers" }
1839
+ ],
1840
+ "reason": "brief explanation"
1841
+ }
1842
+
1843
+ Rules:
1844
+ - entries array can have 1-6 items — split aggressively by topic
1845
+ - Each entry.extract is a SHORT directive telling the distill step WHAT to pull for that target
1846
+ - If not valuable, entries should be empty []
1847
+ - Use existing vault files from the list when available`;
1848
+ }
1849
+ /** Build user prompt for triage */
1850
+ function triageUserPrompt(rawContent, vaultFiles) {
1851
+ return `Evaluate this raw input and decide if it's worth distilling:
1852
+
1853
+ ---RAW CONTENT---
1854
+ ${rawContent}
1855
+ ---END RAW CONTENT---
1856
+ ${vaultFiles.length > 0 ? `\nExisting vault files:\n${vaultFiles.map((f) => `- ${f}`).join("\n")}` : "\nNo existing vault files yet."}
1857
+
1858
+ Route to context/, preferences/, life/, coding/, or work/ as appropriate. Respond with JSON only.`;
1859
+ }
1860
+
1861
+ //#endregion
1862
+ //#region src/distill/triage.ts
1863
+ /** Run triage on a raw file to determine if it's worth distilling */
1864
+ async function triageRawFile(rawContent, vaultFiles) {
1865
+ const system = triageSystemPrompt();
1866
+ const response = await llmCall(triageUserPrompt(rawContent, vaultFiles), system);
1867
+ try {
1868
+ const cleaned = response.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
1869
+ const raw = JSON.parse(cleaned);
1870
+ let entries = [];
1871
+ if (Array.isArray(raw.entries)) entries = raw.entries.filter((e) => typeof e.targetFile === "string" && typeof e.extract === "string").map((e) => ({
1872
+ targetFile: e.targetFile,
1873
+ extract: e.extract
1874
+ }));
1875
+ if (entries.length === 0 && typeof raw.targetFile === "string" && raw.targetFile) entries = [{
1876
+ targetFile: raw.targetFile,
1877
+ extract: "all relevant content"
1878
+ }];
1879
+ return {
1880
+ valuable: Boolean(raw.valuable),
1881
+ entries,
1882
+ reason: raw.reason ?? ""
1883
+ };
1884
+ } catch {
1885
+ return {
1886
+ valuable: false,
1887
+ entries: [],
1888
+ reason: `Failed to parse triage response: ${response.slice(0, 100)}`
1889
+ };
1890
+ }
1891
+ }
1892
+
1893
+ //#endregion
1894
+ //#region src/prompts/distill.ts
1895
+ /** Build system prompt for the distill & merge step */
1896
+ function distillSystemPrompt() {
1897
+ return `You are a personal knowledge distillation assistant.
1898
+ Your job is to extract and merge valuable personal context from raw input into a vault document.
1899
+
1900
+ Two modes:
1901
+
1902
+ A) EXPERIENTIAL content (lessons, tips, experiences):
1903
+ - Extract ONLY conclusions and actionable knowledge — never copy raw text verbatim
1904
+ - If a similar experience already exists, increment its verification count
1905
+ - Each bullet: (source, date | ref: raw/path)
1906
+
1907
+ B) SYSTEM CONTEXT content (identity, environment, habits, bookmarks, etc.):
1908
+ - Do NOT dump raw lists verbatim (e.g. every package path or every bookmark folder)
1909
+ - Synthesize into a compact, HIGH-DENSITY profile:
1910
+ - Identity: name, role, languages, locale, timezone — one line each
1911
+ - Tools: summarize tech stack concisely (e.g. "Full-stack: Node/Python/Go/Rust, IDEs: Cursor+Android Studio")
1912
+ - Workflow: summarize habits (e.g. "Heavy git user (4762 commands), frequent claude CLI, command-line focused")
1913
+ - Life: summarize calendar themes, main apps, browsing focus — with concrete data
1914
+ - Interests: summarize bookmark categories AND top domains as interest tags (e.g. "AI/LLM, GIS, iOS, Python, Web3")
1915
+ - Projects: summarize active areas with names (e.g. "PINAI (PIN-APP-IOS, PIN-AGENT-WEB), consulting, agent-market")
1916
+
1917
+ CRITICAL RULES:
1918
+ - NEVER write "未提供", "未指定", "Not specified", "Unknown" — if data is not in the raw input, simply OMIT that field
1919
+ - Only include sections and fields that have ACTUAL data from the raw input
1920
+ - Do NOT create empty placeholder sections. If an H2 section would be empty, skip it entirely
1921
+ - Each vault file handles ONE topic — only include relevant data from the raw input
1922
+ - Keep information density HIGH: pack maximum facts per line
1923
+ - Maintain H2 (##) section structure
1924
+ - Keep each H2 section between 200-800 tokens
1925
+ - Preserve all existing vault content — only add or update, never remove
1926
+ - Output the COMPLETE updated vault file content (not just the changes)
1927
+ - End each new bullet with source ref: (source, date | ref: raw/path/to/file.md)`;
1928
+ }
1929
+ /** Build user prompt for distill & merge */
1930
+ function distillUserPrompt(rawContent, rawFilePath, existingVaultContent, extractDirective) {
1931
+ return `Distill the following raw input and merge into the vault document:
1932
+
1933
+ ---RAW CONTENT (from: ${rawFilePath})---
1934
+ ${rawContent}
1935
+ ---END RAW CONTENT---
1936
+
1937
+ ${existingVaultContent ? `---EXISTING VAULT DOCUMENT---\n${existingVaultContent}\n---END VAULT DOCUMENT---` : "This is a NEW vault document. Create it with appropriate H1 title and H2 sections."}
1938
+ ${extractDirective ? `\nFOCUS: Only extract data related to: ${extractDirective}. Ignore unrelated content in the raw input.` : ""}
1939
+ Output the COMPLETE updated vault file content.
1940
+ Rules: synthesize into high-density profile. NEVER write "未提供"/"Not specified"/"Unknown" — omit fields with no data instead. Only include sections with actual data.`;
1941
+ }
1942
+
1943
+ //#endregion
1944
+ //#region src/distill/merge.ts
1945
+ /**
1946
+ * Distill raw content and merge into a vault file.
1947
+ * Creates the vault file if it doesn't exist.
1948
+ * @param extractDirective - Optional hint telling LLM what to extract from raw content for this target
1949
+ */
1950
+ async function distillAndMerge(rawContent, rawFilePath, targetFile, extractDirective) {
1951
+ const vaultDir = getVaultDir();
1952
+ const relativePath = targetFile.replace(/^vault\//, "");
1953
+ const fullPath = path.join(vaultDir, relativePath);
1954
+ let existingContent = null;
1955
+ try {
1956
+ existingContent = await fs.readFile(fullPath, "utf-8");
1957
+ } catch {}
1958
+ const system = distillSystemPrompt();
1959
+ const updatedContent = await llmCall(distillUserPrompt(rawContent, rawFilePath, existingContent, extractDirective), system);
1960
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
1961
+ await fs.writeFile(fullPath, updatedContent.trim() + "\n", "utf-8");
1962
+ return fullPath;
1963
+ }
1964
+
1965
+ //#endregion
1966
+ //#region src/distill/index.ts
1967
+ /** List all existing vault files (relative paths like "coding/python.md") */
1968
+ async function listVaultFiles() {
1969
+ const vaultDir = getVaultDir();
1970
+ const files = [];
1971
+ async function walk(dir) {
1972
+ try {
1973
+ const entries = await fs.readdir(dir, { withFileTypes: true });
1974
+ for (const entry of entries) {
1975
+ const fullPath = path.join(dir, entry.name);
1976
+ if (entry.isDirectory()) await walk(fullPath);
1977
+ else if (entry.name.endsWith(".md")) files.push(path.relative(vaultDir, fullPath));
1978
+ }
1979
+ } catch {}
1980
+ }
1981
+ await walk(vaultDir);
1982
+ return files;
1983
+ }
1984
+ /** Run the full distill pipeline on all pending raw files */
1985
+ async function distillPipeline(options = {}) {
1986
+ const result = {
1987
+ processed: 0,
1988
+ valuable: 0,
1989
+ discarded: 0,
1990
+ errors: []
1991
+ };
1992
+ let pendingFiles;
1993
+ if (options.singleFile) pendingFiles = [options.singleFile];
1994
+ else pendingFiles = await listPending();
1995
+ if (pendingFiles.length === 0) {
1996
+ info("No pending raw files to process.");
1997
+ return result;
1998
+ }
1999
+ info(`Found ${pendingFiles.length} pending file(s) to process.`);
2000
+ const vaultFilesList = (await listVaultFiles()).map((f) => `vault/${f}`);
2001
+ for (const filePath of pendingFiles) {
2002
+ const spin = spinner(`Processing ${path.basename(filePath)}...`);
2003
+ try {
2004
+ const rawFileContent = await fs.readFile(filePath, "utf-8");
2005
+ const { content } = parseFrontmatter(rawFileContent);
2006
+ const triage = await triageRawFile(content, vaultFilesList);
2007
+ result.processed++;
2008
+ if (!triage.valuable || triage.entries.length === 0) {
2009
+ spin.succeed(`Discarded: ${path.basename(filePath)} — ${triage.reason}`);
2010
+ result.discarded++;
2011
+ if (!options.dryRun) {
2012
+ const updated = updateRawFrontmatter(rawFileContent, { status: "discarded" });
2013
+ await fs.writeFile(filePath, updated, "utf-8");
2014
+ }
2015
+ continue;
2016
+ }
2017
+ const targets = triage.entries.map((e) => e.targetFile);
2018
+ if (options.dryRun) {
2019
+ spin.succeed(`[DRY RUN] Would distill ${path.basename(filePath)} → ${targets.join(", ")}`);
2020
+ result.valuable++;
2021
+ continue;
2022
+ }
2023
+ const mergedPaths = [];
2024
+ for (const entry of triage.entries) {
2025
+ const targetFile = entry.targetFile || "vault/context/misc.md";
2026
+ const vaultPath = await distillAndMerge(content, filePath, targetFile, entry.extract);
2027
+ mergedPaths.push(vaultPath);
2028
+ if (!vaultFilesList.includes(targetFile)) vaultFilesList.push(targetFile);
2029
+ }
2030
+ result.valuable++;
2031
+ const updated = updateRawFrontmatter(rawFileContent, {
2032
+ status: "processed",
2033
+ distilled_to: mergedPaths.map((p) => path.relative(path.dirname(filePath).replace(/\/raw\/.*/, "/raw"), p)).join(", ")
2034
+ });
2035
+ await fs.writeFile(filePath, updated, "utf-8");
2036
+ spin.succeed(`Distilled ${path.basename(filePath)} → ${targets.join(", ")} (${triage.entries.length} entries)`);
2037
+ } catch (err) {
2038
+ const msg = err instanceof Error ? err.message : String(err);
2039
+ spin.fail(`Error processing ${path.basename(filePath)}: ${msg}`);
2040
+ result.errors.push(`${filePath}: ${msg}`);
2041
+ }
2042
+ }
2043
+ return result;
2044
+ }
2045
+
2046
+ //#endregion
2047
+ //#region src/cli/register.distill.ts
2048
+ function registerDistillCommand(program) {
2049
+ program.command("distill").description("Distill pending raw files into vault knowledge").option("--file <path>", "Process a single raw file").option("--dry-run", "Preview what would be distilled without writing").action(async (opts) => {
2050
+ try {
2051
+ const result = await distillPipeline({
2052
+ singleFile: opts.file,
2053
+ dryRun: opts.dryRun
2054
+ });
2055
+ log("");
2056
+ log(bold("Distill summary:"));
2057
+ log(` Processed: ${result.processed}`);
2058
+ log(` Valuable: ${result.valuable}`);
2059
+ log(` Discarded: ${result.discarded}`);
2060
+ if (result.errors.length > 0) {
2061
+ log(` Errors: ${result.errors.length}`);
2062
+ for (const e of result.errors) error(` ${e}`);
2063
+ }
2064
+ log("");
2065
+ if (result.valuable > 0 && !opts.dryRun) info("Vault updated. Run \"pai generate\" to update SKILL.md profiles.");
2066
+ } catch (err) {
2067
+ const msg = err instanceof Error ? err.message : String(err);
2068
+ error(`Distill failed: ${msg}`);
2069
+ process.exit(1);
2070
+ }
2071
+ });
2072
+ }
2073
+
2074
+ //#endregion
2075
+ //#region src/prompts/generate.ts
2076
+ /** Build system prompt for SKILL.md generation */
2077
+ function generateSystemPrompt() {
2078
+ return `You are generating a concise SKILL.md personal context file for an AI agent.
2079
+ This file helps the agent understand the user's background, preferences, and experiences.
2080
+
2081
+ Rules:
2082
+ - Be extremely concise — every line must carry HIGH information density
2083
+ - Pack maximum concrete facts per bullet (names, numbers, specific tools)
2084
+ - Prioritize experiences with higher verification counts when present
2085
+ - Use bullet points for each piece of context
2086
+ - Follow the exact section structure provided
2087
+ - Do NOT include generic advice — only user-specific context
2088
+ - NEVER write filler like "未提供", "Not specified" or vague statements
2089
+ - Write in the same language as the source content
2090
+
2091
+ When vault content includes identity/tools/workflow/interests data:
2092
+ - "Who I Am": real name, email, languages with specifics, locale, timezone, tech identity
2093
+ - "Preferences": specific tools by name, workflow patterns with data (e.g. "git: 4762 commands"), communication style
2094
+ - "Hard-won Lessons": only include if genuine lessons exist; otherwise omit or make it 1-2 lines
2095
+ - "Current Work": specific project names, focus areas, active repos — be concrete`;
2096
+ }
2097
+ /** Build user prompt for SKILL.md generation */
2098
+ function generateUserPrompt(profileName, vaultContents, maxLines) {
2099
+ return `Generate a SKILL.md profile called "${profileName}" from the following vault content.
2100
+ Maximum ${maxLines} lines total.
2101
+
2102
+ Required sections:
2103
+ # Personal Context — ${profileName}
2104
+
2105
+ ## Who I Am
2106
+ ## Preferences
2107
+ ## Hard-won Lessons
2108
+ ## Current Work
2109
+
2110
+ ---VAULT CONTENT---
2111
+ ${vaultContents}
2112
+ ---END VAULT CONTENT---
2113
+
2114
+ Generate the SKILL.md now. Stay within ${maxLines} lines.
2115
+ IMPORTANT: include ALL concrete facts from the vault — names, numbers, tools, project names, bookmark categories, browsing domains. Do not summarize away specific data. Never write filler or "未提供".`;
2116
+ }
2117
+
2118
+ //#endregion
2119
+ //#region src/generate/index.ts
2120
+ /** Recursively list all markdown files in a directory */
2121
+ async function walkMd(dir) {
2122
+ const results = [];
2123
+ try {
2124
+ const entries = await fs.readdir(dir, { withFileTypes: true });
2125
+ for (const entry of entries) {
2126
+ const full = path.join(dir, entry.name);
2127
+ if (entry.isDirectory()) results.push(...await walkMd(full));
2128
+ else if (entry.name.endsWith(".md")) results.push(full);
2129
+ }
2130
+ } catch {}
2131
+ return results;
2132
+ }
2133
+ /** Collect vault file contents matching scope patterns */
2134
+ async function collectVaultContent(scope) {
2135
+ const vaultDir = getVaultDir();
2136
+ const allFiles = await walkMd(vaultDir);
2137
+ const matched = [];
2138
+ for (const file of allFiles) {
2139
+ const relative = "vault/" + path.relative(vaultDir, file);
2140
+ for (const pattern of scope) if (minimatch(relative, pattern)) {
2141
+ matched.push(file);
2142
+ break;
2143
+ }
2144
+ }
2145
+ const contents = [];
2146
+ for (const file of matched) try {
2147
+ const content = await fs.readFile(file, "utf-8");
2148
+ contents.push(`--- ${path.relative(vaultDir, file)} ---\n${content}`);
2149
+ } catch {}
2150
+ return contents.join("\n\n");
2151
+ }
2152
+ /** Generate a single SKILL.md profile */
2153
+ async function generateProfile(profileName) {
2154
+ const profileDef = (await loadProfiles()).profiles[profileName];
2155
+ if (!profileDef) throw new Error(`Profile "${profileName}" not found in profiles.json5`);
2156
+ const vaultContent = await collectVaultContent(profileDef.scope);
2157
+ if (!vaultContent.trim()) throw new Error(`No vault content found for profile "${profileName}". Run 'pai distill' first.`);
2158
+ const system = generateSystemPrompt();
2159
+ const skillMd = await llmCall(generateUserPrompt(profileName, vaultContent, profileDef.maxLines), system);
2160
+ const skillsDir = getSkillsDir();
2161
+ await fs.mkdir(skillsDir, { recursive: true });
2162
+ const outPath = path.join(skillsDir, `${profileName}.md`);
2163
+ await fs.writeFile(outPath, skillMd.trim() + "\n", "utf-8");
2164
+ return outPath;
2165
+ }
2166
+ /** Generate all profiles defined in profiles.json5 */
2167
+ async function generateAll() {
2168
+ const profiles = await loadProfiles();
2169
+ const profileNames = Object.keys(profiles.profiles);
2170
+ if (profileNames.length === 0) {
2171
+ warn("No profiles defined in profiles.json5");
2172
+ return [];
2173
+ }
2174
+ const results = [];
2175
+ for (const name of profileNames) {
2176
+ const spin = spinner(`Generating profile: ${name}...`);
2177
+ try {
2178
+ const outPath = await generateProfile(name);
2179
+ spin.succeed(`Generated ${name} → ${outPath}`);
2180
+ results.push(outPath);
2181
+ } catch (err) {
2182
+ const msg = err instanceof Error ? err.message : String(err);
2183
+ spin.fail(`Failed to generate ${name}: ${msg}`);
2184
+ }
2185
+ }
2186
+ return results;
2187
+ }
2188
+
2189
+ //#endregion
2190
+ //#region src/cli/register.generate.ts
2191
+ function registerGenerateCommand(program) {
2192
+ program.command("generate").description("Generate SKILL.md profiles from vault content").option("--profile <name>", "Generate a specific profile only").action(async (opts) => {
2193
+ try {
2194
+ if (opts.profile) {
2195
+ const spin = spinner(`Generating profile: ${opts.profile}...`);
2196
+ try {
2197
+ const outPath = await generateProfile(opts.profile);
2198
+ spin.succeed(`Generated: ${outPath}`);
2199
+ } catch (err) {
2200
+ spin.fail("Generation failed");
2201
+ throw err;
2202
+ }
2203
+ } else {
2204
+ const results = await generateAll();
2205
+ if (results.length > 0) {
2206
+ log("");
2207
+ success(`Generated ${results.length} SKILL.md profile(s).`);
2208
+ info("These files can be used by AI agents (Cursor, Claude, etc.).");
2209
+ }
2210
+ }
2211
+ } catch (err) {
2212
+ const msg = err instanceof Error ? err.message : String(err);
2213
+ error(`Generate failed: ${msg}`);
2214
+ process.exit(1);
2215
+ }
2216
+ });
2217
+ }
2218
+
2219
+ //#endregion
2220
+ //#region src/search/index.ts
2221
+ /**
2222
+ * Hybrid search via QMD.
2223
+ * - "query" (default): BM25 + vector + expansion + reranking (~5s, best quality)
2224
+ * - "search": BM25 keyword + rg grep fallback (~50ms, fast)
2225
+ * - "vsearch": Vector similarity only (~2s, good for semantic)
2226
+ *
2227
+ * For "search" (fast) mode, QMD BM25 doesn't support CJK tokenization,
2228
+ * so we supplement with ripgrep to cover Chinese/Japanese/Korean.
2229
+ */
2230
+ async function search(query, collection = "vault", n = 5, mode = "query") {
2231
+ if (!await isQmdAvailable()) throw new Error("QMD is not installed. Install with: npm install -g https://github.com/tobi/qmd");
2232
+ let results = parseSearchResults(await execQmd([
2233
+ mode,
2234
+ query,
2235
+ "--collection",
2236
+ collection,
2237
+ "--json",
2238
+ "-n",
2239
+ String(n)
2240
+ ]));
2241
+ if (mode === "search" && results.length === 0 && hasCjk(query)) results = await grepFallback(query, collection, n);
2242
+ return results;
2243
+ }
2244
+ /** Parse QMD JSON search output into SearchResult[] */
2245
+ function parseSearchResults(stdout) {
2246
+ try {
2247
+ const parsed = JSON.parse(stdout);
2248
+ if (!Array.isArray(parsed)) return [];
2249
+ return parsed.map((item) => ({
2250
+ file: item.file ?? "",
2251
+ title: item.title ?? void 0,
2252
+ snippet: item.snippet ?? item.content?.slice(0, 200) ?? "",
2253
+ score: item.score
2254
+ }));
2255
+ } catch {
2256
+ return [];
2257
+ }
2258
+ }
2259
+ /** Check if a string contains CJK characters */
2260
+ function hasCjk(text) {
2261
+ return /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(text);
2262
+ }
2263
+ /**
2264
+ * grep fallback for CJK text that BM25 can't tokenize.
2265
+ * Runs: grep -rl "query" <dir> --include="*.md"
2266
+ * Then reads matched files and extracts context.
2267
+ */
2268
+ async function grepFallback(query, collection, n) {
2269
+ const baseDir = collection === "raw" ? getRawDir() : getVaultDir();
2270
+ const stdout = await new Promise((resolve) => {
2271
+ execFile("grep", [
2272
+ "-rl",
2273
+ "--include=*.md",
2274
+ query,
2275
+ baseDir
2276
+ ], {
2277
+ encoding: "utf-8",
2278
+ timeout: 5e3
2279
+ }, (_err, out) => {
2280
+ resolve(out ?? "");
2281
+ });
2282
+ });
2283
+ if (!stdout.trim()) return [];
2284
+ const files = stdout.trim().split("\n").slice(0, n);
2285
+ const results = [];
2286
+ for (const filePath of files) {
2287
+ if (!filePath) continue;
2288
+ const snippet = (await new Promise((resolve) => {
2289
+ execFile("grep", [
2290
+ "-m1",
2291
+ "-C1",
2292
+ query,
2293
+ filePath
2294
+ ], {
2295
+ encoding: "utf-8",
2296
+ timeout: 2e3
2297
+ }, (_err, out) => resolve(out ?? ""));
2298
+ })).replace(/\n/g, " ").trim().slice(0, 160);
2299
+ const relative = path.relative(baseDir, filePath);
2300
+ const titleLine = await new Promise((resolve) => {
2301
+ execFile("grep", [
2302
+ "-m1",
2303
+ "^# ",
2304
+ filePath
2305
+ ], {
2306
+ encoding: "utf-8",
2307
+ timeout: 1e3
2308
+ }, (_err, out) => resolve(out?.replace(/^#\s+/, "").trim() ?? ""));
2309
+ });
2310
+ results.push({
2311
+ file: `qmd://${collection}/${relative}`,
2312
+ title: titleLine || path.basename(filePath, ".md"),
2313
+ snippet,
2314
+ score: 1
2315
+ });
2316
+ }
2317
+ return results;
2318
+ }
2319
+ /** Update QMD index: runs `qmd update` + `qmd embed` */
2320
+ async function updateIndex() {
2321
+ await execQmd(["update"]);
2322
+ await execQmd(["embed"]);
2323
+ }
2324
+
2325
+ //#endregion
2326
+ //#region src/cli/register.search.ts
2327
+ function registerSearchCommand(program) {
2328
+ program.command("search <query>").description("Search vault (default) or raw via QMD").option("--raw", "Search raw collection instead of vault").option("--all", "Search all collections").option("--fast", "BM25 keyword search only (instant, no AI)").option("--vector", "Vector similarity search only").option("--json", "Output results as JSON (for agent consumption)").option("-n, --num <number>", "Number of results", "5").action(async (query, opts) => {
2329
+ try {
2330
+ const n = parseInt(opts.num, 10);
2331
+ const collection = opts.raw ? "raw" : opts.all ? void 0 : "vault";
2332
+ const mode = opts.fast ? "search" : opts.vector ? "vsearch" : "query";
2333
+ if (opts.all) {
2334
+ const [vaultResults, rawResults] = await Promise.all([search(query, "vault", n, mode), search(query, "raw", n, mode)]);
2335
+ if (opts.json) {
2336
+ process.stdout.write(JSON.stringify({
2337
+ vault: vaultResults,
2338
+ raw: rawResults
2339
+ }, null, 2) + "\n");
2340
+ return;
2341
+ }
2342
+ log("");
2343
+ log(bold(`Search results for: "${query}"`));
2344
+ if (vaultResults.length > 0) {
2345
+ log("\n" + bold("Vault:"));
2346
+ for (const r of vaultResults) printResult(r);
2347
+ }
2348
+ if (rawResults.length > 0) {
2349
+ log("\n" + bold("Raw:"));
2350
+ for (const r of rawResults) printResult(r);
2351
+ }
2352
+ if (vaultResults.length === 0 && rawResults.length === 0) info("No results found.");
2353
+ } else {
2354
+ const results = await search(query, collection, n, mode);
2355
+ if (opts.json) {
2356
+ process.stdout.write(JSON.stringify(results, null, 2) + "\n");
2357
+ return;
2358
+ }
2359
+ log("");
2360
+ log(bold(`Search results for: "${query}" (${collection ?? "all"})`));
2361
+ if (results.length === 0) info("No results found.");
2362
+ else for (const r of results) printResult(r);
2363
+ }
2364
+ log("");
2365
+ } catch (err) {
2366
+ const msg = err instanceof Error ? err.message : String(err);
2367
+ error(`Search failed: ${msg}`);
2368
+ process.exit(1);
2369
+ }
2370
+ });
2371
+ }
2372
+ function printResult(r) {
2373
+ const score = r.score != null ? dim(` (${r.score.toFixed(2)})`) : "";
2374
+ log(` ${bold(r.title ?? r.file)}${score}`);
2375
+ log(` ${dim(r.snippet.slice(0, 120))}`);
2376
+ log(` ${dim(r.file)}`);
2377
+ log("");
2378
+ }
2379
+
2380
+ //#endregion
2381
+ //#region src/cli/register.index.ts
2382
+ function registerIndexCommand(program) {
2383
+ program.command("index").description("Update QMD index (qmd update + qmd embed)").action(async () => {
2384
+ const spin = spinner("Updating QMD index...");
2385
+ try {
2386
+ await updateIndex();
2387
+ spin.succeed("QMD index updated successfully.");
2388
+ } catch (err) {
2389
+ spin.fail("Index update failed");
2390
+ const msg = err instanceof Error ? err.message : String(err);
2391
+ error(msg);
2392
+ process.exit(1);
2393
+ }
2394
+ });
2395
+ }
2396
+
2397
+ //#endregion
2398
+ //#region src/cli/register.import.ts
2399
+ function registerImportCommand(program) {
2400
+ program.command("import").description("Import data from connector or directory (use --source mac for system scan)").requiredOption("--source <source>", "Data source (mac, gmail, calendar, twitter, etc.)").option("--path <dir>", "Path to data directory or file (not needed for mac, gmail, calendar)").option("--dry-run", "Preview what would be imported without writing").option("--days <n>", "For gmail/calendar: last N days to fetch (default 30)", "30").option("--query <q>", "For gmail: Gmail search query (e.g. is:important)").action(async (opts) => {
2401
+ try {
2402
+ if (opts.source === "mac") {
2403
+ const { scanMac } = await import("./mac-C9SDXZGK.mjs");
2404
+ const result = await scanMac({ dryRun: opts.dryRun });
2405
+ if (opts.dryRun) return;
2406
+ log("");
2407
+ success(`Mac scan complete: ${result.created} created, ${result.updated} updated, ${result.skipped} unchanged`);
2408
+ if (result.failed.length > 0) warn(`${result.failed.length} failed: ${result.failed.join(", ")}`);
2409
+ info("Run \"pai distill\" to process scanned data.");
2410
+ return;
2411
+ }
2412
+ if (opts.source === "gmail") {
2413
+ const { syncGmail } = await import("./gmail-B9ja9sKN.mjs");
2414
+ const parsedDays = parseInt(opts.days ?? "30", 10);
2415
+ const days = Number.isNaN(parsedDays) ? 30 : parsedDays;
2416
+ const doSync = () => syncGmail({
2417
+ days,
2418
+ query: opts.query,
2419
+ maxResults: 100
2420
+ });
2421
+ await encryption.loadKey();
2422
+ await googleOAuth.init();
2423
+ let entries;
2424
+ const spinGmail = opts.dryRun ? null : spinner("Syncing Gmail...");
2425
+ try {
2426
+ entries = await doSync();
2427
+ } catch (err) {
2428
+ if (err instanceof Error && err.message.includes("Not authenticated")) {
2429
+ spinGmail?.stop();
2430
+ info("Not authenticated; starting Google OAuth...");
2431
+ await googleOAuth.authorize();
2432
+ const spinRetry = opts.dryRun ? null : spinner("Syncing Gmail...");
2433
+ entries = await doSync();
2434
+ spinRetry?.stop();
2435
+ } else throw err;
2436
+ } finally {
2437
+ spinGmail?.stop();
2438
+ }
2439
+ if (opts.dryRun) {
2440
+ info(`Would import ${entries.length} Gmail message(s).`);
2441
+ return;
2442
+ }
2443
+ let created = 0;
2444
+ let updated = 0;
2445
+ let skipped = 0;
2446
+ for (const entry of entries) {
2447
+ const status = await addConnectorEntry("gmail", entry);
2448
+ if (status === "created") created++;
2449
+ else if (status === "updated") updated++;
2450
+ else skipped++;
2451
+ }
2452
+ success(`Gmail import: ${created} created, ${updated} updated, ${skipped} unchanged`);
2453
+ info("Run \"pai distill\" to process imported data.");
2454
+ return;
2455
+ }
2456
+ if (opts.source === "calendar") {
2457
+ const { syncCalendar } = await import("./calendar-BHcM4wfQ.mjs");
2458
+ const parsedDays = parseInt(opts.days ?? "30", 10);
2459
+ const days = Number.isNaN(parsedDays) ? 30 : parsedDays;
2460
+ const doSync = () => syncCalendar({
2461
+ lookbackDays: days,
2462
+ lookforwardDays: 90
2463
+ });
2464
+ await encryption.loadKey();
2465
+ await googleOAuth.init();
2466
+ let entries;
2467
+ const spinCal = opts.dryRun ? null : spinner("Syncing Calendar...");
2468
+ try {
2469
+ entries = await doSync();
2470
+ } catch (err) {
2471
+ if (err instanceof Error && err.message.includes("Not authenticated")) {
2472
+ spinCal?.stop();
2473
+ info("Not authenticated; starting Google OAuth...");
2474
+ await googleOAuth.authorize();
2475
+ const spinRetry = opts.dryRun ? null : spinner("Syncing Calendar...");
2476
+ entries = await doSync();
2477
+ spinRetry?.stop();
2478
+ } else throw err;
2479
+ } finally {
2480
+ spinCal?.stop();
2481
+ }
2482
+ if (opts.dryRun) {
2483
+ info(`Would import ${entries.length} calendar event(s).`);
2484
+ return;
2485
+ }
2486
+ let created = 0;
2487
+ let updated = 0;
2488
+ let skipped = 0;
2489
+ for (const entry of entries) {
2490
+ const status = await addConnectorEntry("calendar", entry);
2491
+ if (status === "created") created++;
2492
+ else if (status === "updated") updated++;
2493
+ else skipped++;
2494
+ }
2495
+ success(`Calendar import: ${created} created, ${updated} updated, ${skipped} unchanged`);
2496
+ info("Run \"pai distill\" to process imported data.");
2497
+ return;
2498
+ }
2499
+ if (!opts.path) {
2500
+ error("--path is required for this source. Usage: pai import --source <name> --path <dir>");
2501
+ process.exit(1);
2502
+ }
2503
+ const sourcePath = opts.path;
2504
+ const source = `connector/${opts.source}`;
2505
+ const stat = await fs.stat(sourcePath);
2506
+ const outDir = path.join(getRawDir(), "connector", opts.source);
2507
+ await fs.mkdir(outDir, { recursive: true });
2508
+ let imported = 0;
2509
+ if (stat.isDirectory()) {
2510
+ const entries = await fs.readdir(sourcePath, { withFileTypes: true });
2511
+ for (const entry of entries) if (entry.isFile() && (entry.name.endsWith(".md") || entry.name.endsWith(".txt") || entry.name.endsWith(".json"))) {
2512
+ const content = await fs.readFile(path.join(sourcePath, entry.name), "utf-8");
2513
+ const title = path.basename(entry.name, path.extname(entry.name));
2514
+ const filename = generateRawFilename(title);
2515
+ const fileContent = createRawFile({
2516
+ source,
2517
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2518
+ status: "pending"
2519
+ }, title, content);
2520
+ await fs.writeFile(path.join(outDir, filename), fileContent, "utf-8");
2521
+ imported++;
2522
+ }
2523
+ } else {
2524
+ const content = await fs.readFile(sourcePath, "utf-8");
2525
+ const title = path.basename(sourcePath, path.extname(sourcePath));
2526
+ const filename = generateRawFilename(title);
2527
+ const fileContent = createRawFile({
2528
+ source,
2529
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
2530
+ status: "pending"
2531
+ }, title, content);
2532
+ await fs.writeFile(path.join(outDir, filename), fileContent, "utf-8");
2533
+ imported = 1;
2534
+ }
2535
+ success(`Imported ${imported} file(s) from ${opts.source}`);
2536
+ info("Run \"pai distill\" to process imported data.");
2537
+ } catch (err) {
2538
+ const msg = err instanceof Error ? err.message : String(err);
2539
+ error(`Import failed: ${msg}`);
2540
+ process.exit(1);
2541
+ }
2542
+ });
2543
+ }
2544
+
2545
+ //#endregion
2546
+ //#region src/cli/register.status.ts
2547
+ function registerStatusCommand(program) {
2548
+ program.command("status").description("Show pai data status overview").option("--json", "Output as JSON (for agent consumption)").action(async (opts) => {
2549
+ try {
2550
+ const allRaw = await listAll();
2551
+ const pendingRaw = await listPending();
2552
+ const vaultFiles = await countFiles(getVaultDir());
2553
+ const skillFiles = await countFiles(getSkillsDir());
2554
+ if (opts.json) {
2555
+ process.stdout.write(JSON.stringify({
2556
+ raw: {
2557
+ total: allRaw.length,
2558
+ pending: pendingRaw.length
2559
+ },
2560
+ vault: { files: vaultFiles },
2561
+ profiles: skillFiles
2562
+ }, null, 2) + "\n");
2563
+ return;
2564
+ }
2565
+ log("");
2566
+ log(bold("pai status"));
2567
+ log("─".repeat(40));
2568
+ log(` Raw files: ${allRaw.length} total, ${pendingRaw.length} pending`);
2569
+ log(` Vault files: ${vaultFiles}`);
2570
+ log(` SKILL profiles: ${skillFiles}`);
2571
+ log("");
2572
+ if (pendingRaw.length > 0) info(`${pendingRaw.length} file(s) waiting to be distilled. Run "pai distill" to process.`);
2573
+ if (vaultFiles === 0) info("No vault content yet. Run \"pai add <text>\" then \"pai distill\".");
2574
+ } catch (err) {
2575
+ const msg = err instanceof Error ? err.message : String(err);
2576
+ error(`Status check failed: ${msg}`);
2577
+ process.exit(1);
2578
+ }
2579
+ });
2580
+ }
2581
+ async function countFiles(dir) {
2582
+ let count = 0;
2583
+ try {
2584
+ const entries = await fs.readdir(dir, { withFileTypes: true });
2585
+ for (const entry of entries) {
2586
+ const full = path.join(dir, entry.name);
2587
+ if (entry.isDirectory()) count += await countFiles(full);
2588
+ else if (entry.name.endsWith(".md")) count++;
2589
+ }
2590
+ } catch {}
2591
+ return count;
2592
+ }
2593
+
2594
+ //#endregion
2595
+ //#region src/cli/register.reset.ts
2596
+ function askConfirm(question) {
2597
+ const rl = readline.createInterface({
2598
+ input: process.stdin,
2599
+ output: process.stdout
2600
+ });
2601
+ return new Promise((resolve) => {
2602
+ rl.question(question, (answer) => {
2603
+ rl.close();
2604
+ resolve(/^y|yes$/i.test(answer.trim()));
2605
+ });
2606
+ });
2607
+ }
2608
+ function registerResetCommand(program) {
2609
+ program.command("reset").description("Remove all pai data and re-initialize (clean slate for testing)").option("--force", "Skip confirmation prompt").action(async (opts) => {
2610
+ const paiHome = getPaiHome();
2611
+ if (!opts.force) {
2612
+ if (!await askConfirm(`Remove ALL data at ${paiHome} and re-initialize? [y/N] `)) {
2613
+ info("Reset cancelled.");
2614
+ return;
2615
+ }
2616
+ }
2617
+ const spin = spinner(`Resetting pai at ${paiHome}...`);
2618
+ try {
2619
+ await fs.rm(paiHome, {
2620
+ recursive: true,
2621
+ force: true
2622
+ });
2623
+ await runInit({ overwriteConfig: true });
2624
+ spin.succeed(`pai reset complete. Fresh state at ${paiHome}`);
2625
+ } catch (err) {
2626
+ spin.fail("Reset failed");
2627
+ const msg = err instanceof Error ? err.message : String(err);
2628
+ error(msg);
2629
+ process.exit(1);
2630
+ }
2631
+ });
2632
+ }
2633
+
2634
+ //#endregion
2635
+ //#region src/ask/system-prompt.ts
2636
+ /**
2637
+ * System prompt for the ask agent (personal AI secretary).
2638
+ * Built in sections, similar to clawdbot system-prompt.ts.
2639
+ */
2640
+ function buildAskSystemPrompt() {
2641
+ const sections = [];
2642
+ const paiHome = getPaiHome();
2643
+ const homeDir = os.homedir();
2644
+ sections.push(`You are a personal AI secretary who knows the user intimately.
2645
+ Your job: answer questions about the user accurately, specifically, and honestly.
2646
+ Today's date: ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}
2647
+ User home: ${homeDir}
2648
+ PAI data directory: ${paiHome}`);
2649
+ sections.push(`## Data Layout
2650
+ ${paiHome}/
2651
+ ├── profile.md # Compiled user profile (identity, env, projects)
2652
+ ├── raw/ # Original immutable data with timestamps
2653
+ │ ├── local/ # Manual input
2654
+ │ ├── web/ # URL scrapes
2655
+ │ └── connector/ # Imported data from connectors (dynamic — use queryConnector to discover)
2656
+ ├── vault/ # Distilled knowledge (experiences, preferences, lessons)
2657
+ │ ├── coding/
2658
+ │ ├── context/
2659
+ │ ├── life/
2660
+ │ └── preferences/
2661
+ └── config/ # Configuration files`);
2662
+ sections.push(`## Tool Strategy
2663
+
2664
+ ### Knowledge tools (use first — fast, structured)
2665
+ - readProfile: Read compiled user profile — fastest way to understand who the user is
2666
+ - searchVault: Search distilled knowledge. Ranked by RELEVANCE, not recency.
2667
+ - searchRaw: Search original raw data. Has timestamps in frontmatter. Ranked by RELEVANCE, not recency.
2668
+
2669
+ ### Connector data tool (for time/fact-based questions — calendar, email, etc.)
2670
+ - queryConnector: Query imported data from ANY connector source.
2671
+ - Call WITHOUT source → discovers available sources (calendar, gmail, mac, or any future connector)
2672
+ - Call WITH source + range → returns entries sorted by date, with structured fields
2673
+ - Supports range: upcoming/today/this_week/past_week/past_month/all
2674
+ - ALWAYS use this for schedule, email, or any time-sensitive imported data questions
2675
+ - Examples: "下一个会议" → queryConnector(source="calendar", range="upcoming", limit=3)
2676
+ - Examples: "最近邮件" → queryConnector(source="gmail", range="past_week", sort="desc")
2677
+
2678
+ ### Filesystem tools (use to go deeper)
2679
+ - readFile: Read full content of any file. ALWAYS use this after search finds a relevant file — search snippets are too short to answer well.
2680
+ - grep: Search file contents with regex (ripgrep)
2681
+ - glob: Find files by pattern
2682
+ - ls: List directory contents
2683
+ - bash: Execute shell commands
2684
+
2685
+ ### CRITICAL: Always read files after searching
2686
+ Search tools return short snippets. When you find relevant results:
2687
+ 1. Use readFile to read the full content of the top results
2688
+ 2. Extract specific details (names, dates, numbers) from the full text
2689
+ 3. NEVER answer based on search snippets alone — they are incomplete
2690
+
2691
+ ### Recommended flow
2692
+ 1. For calendar/email/time-sensitive data → use queryConnector FIRST (time-aware, structured)
2693
+ 2. readProfile → understand who the user is
2694
+ 3. searchVault/searchRaw → find relevant topics
2695
+ 4. readFile → read full content of promising results
2696
+ 5. grep/glob/bash → dig deeper if needed
2697
+
2698
+ ### Time-sensitive queries
2699
+ searchVault and searchRaw rank by relevance, NOT by recency.
2700
+ When the user asks about schedules, recent events, emails, or activity:
2701
+ - Use queryConnector — it parses dates from content and supports time range filtering
2702
+ - For system state: use bash (e.g. \`git -C <project_dir> log --oneline -20 --since="1 week ago"\`)
2703
+ - Do NOT rely solely on search for time-based questions — search has no time ranking`);
2704
+ sections.push(`## Rules
2705
+ - ONLY answer based on data from tools — NEVER fabricate information
2706
+ - If you cannot find the answer after thorough searching, say so honestly
2707
+ - Be specific: use real names, versions, dates, numbers from the data
2708
+ - Be concise but substantive: answer like a knowledgeable personal secretary
2709
+ - NEVER give empty or placeholder answers — if data is sparse, say what you found and what's missing
2710
+ - When citing sources, prefer qmd:// paths from vault/raw searches`);
2711
+ return sections.join("\n\n");
2712
+ }
2713
+
2714
+ //#endregion
2715
+ //#region src/ask/tools-meta.ts
2716
+ const BASH_TIMEOUT_MS = 3e4;
2717
+ const BASH_OUTPUT_MAX_BYTES = 10 * 1024;
2718
+ function resolvePath(raw) {
2719
+ const trimmed = raw.trim();
2720
+ if (trimmed.startsWith("~")) return path.join(os.homedir(), trimmed.slice(1).replace(/^\//, ""));
2721
+ return path.resolve(trimmed);
2722
+ }
2723
+ const ReadFileParams = Type.Object({
2724
+ path: Type.String({ description: "Absolute or relative file path" }),
2725
+ offset: Type.Optional(Type.Number({ description: "Start line (1-based)" })),
2726
+ limit: Type.Optional(Type.Number({ description: "Number of lines to read" }))
2727
+ });
2728
+ const readFileTool = {
2729
+ name: "readFile",
2730
+ label: "Read File",
2731
+ description: "Read file contents. Supports offset/limit for large files. Path can be absolute or relative; ~ expands to home directory.",
2732
+ parameters: ReadFileParams,
2733
+ execute: async (_toolCallId, params) => {
2734
+ const resolved = resolvePath(params.path);
2735
+ try {
2736
+ const lines = (await fs.readFile(resolved, "utf-8")).split("\n");
2737
+ const start = Math.min(lines.length, params.offset != null ? Math.max(0, params.offset - 1) : 0);
2738
+ const end = params.limit != null ? Math.min(lines.length, start + params.limit) : lines.length;
2739
+ return {
2740
+ content: [{
2741
+ type: "text",
2742
+ text: lines.slice(start, end).join("\n")
2743
+ }],
2744
+ details: {
2745
+ path: resolved,
2746
+ lines: end - start,
2747
+ totalLines: lines.length
2748
+ }
2749
+ };
2750
+ } catch (err) {
2751
+ const msg = err instanceof Error ? err.message : String(err);
2752
+ return {
2753
+ content: [{
2754
+ type: "text",
2755
+ text: `Error: ${msg}`
2756
+ }],
2757
+ details: {
2758
+ error: msg,
2759
+ path: resolved
2760
+ }
2761
+ };
2762
+ }
2763
+ }
2764
+ };
2765
+ const GrepParams = Type.Object({
2766
+ pattern: Type.String({ description: "Regex pattern to search" }),
2767
+ path: Type.Optional(Type.String({ description: "File or directory to search in" })),
2768
+ glob: Type.Optional(Type.String({ description: "File glob filter, e.g. '*.ts'" })),
2769
+ maxResults: Type.Optional(Type.Number({ description: "Max matching lines to return (default 20)" }))
2770
+ });
2771
+ const grepTool = {
2772
+ name: "grep",
2773
+ label: "Grep",
2774
+ description: "Search file contents using ripgrep (rg). Returns matching lines with context.",
2775
+ parameters: GrepParams,
2776
+ execute: async (_toolCallId, params) => {
2777
+ const maxResults = params.maxResults ?? 20;
2778
+ const args = [
2779
+ "-n",
2780
+ "-m",
2781
+ String(maxResults),
2782
+ params.pattern
2783
+ ];
2784
+ if (params.path) args.push(resolvePath(params.path));
2785
+ if (params.glob) args.push("--glob", params.glob);
2786
+ return new Promise((resolve) => {
2787
+ execFile("rg", args, {
2788
+ encoding: "utf-8",
2789
+ timeout: 1e4,
2790
+ maxBuffer: 512 * 1024
2791
+ }, (err, stdout, stderr) => {
2792
+ if (err) {
2793
+ if (err.code === "1" || err.code === 1) {
2794
+ resolve({
2795
+ content: [{
2796
+ type: "text",
2797
+ text: "No matches found."
2798
+ }],
2799
+ details: {
2800
+ matches: [],
2801
+ count: 0
2802
+ }
2803
+ });
2804
+ return;
2805
+ }
2806
+ resolve({
2807
+ content: [{
2808
+ type: "text",
2809
+ text: `Error: ${stderr?.trim() || err.message}`
2810
+ }],
2811
+ details: { error: stderr?.trim() || err.message }
2812
+ });
2813
+ return;
2814
+ }
2815
+ const lines = stdout.trim().split("\n").slice(0, maxResults);
2816
+ resolve({
2817
+ content: [{
2818
+ type: "text",
2819
+ text: lines.join("\n")
2820
+ }],
2821
+ details: {
2822
+ matches: lines,
2823
+ count: lines.length
2824
+ }
2825
+ });
2826
+ });
2827
+ });
2828
+ }
2829
+ };
2830
+ async function globWalk(dir, pattern, results, baseDir) {
2831
+ const { minimatch } = await import("minimatch");
2832
+ const mm = new minimatch.Minimatch(pattern, { dot: true });
2833
+ let entries;
2834
+ try {
2835
+ entries = await fs.readdir(dir, { withFileTypes: true });
2836
+ } catch {
2837
+ return;
2838
+ }
2839
+ for (const e of entries) {
2840
+ const full = path.join(dir, e.name);
2841
+ const relative = path.relative(baseDir, full);
2842
+ if (e.isDirectory()) await globWalk(full, pattern, results, baseDir);
2843
+ else if (mm.match(relative) || mm.match(e.name)) results.push(full);
2844
+ }
2845
+ }
2846
+ const GlobParams = Type.Object({
2847
+ pattern: Type.String({ description: "Glob pattern, e.g. '**/*.md'" }),
2848
+ cwd: Type.Optional(Type.String({ description: "Base directory (default: current)" }))
2849
+ });
2850
+ const globTool = {
2851
+ name: "glob",
2852
+ label: "Find Files",
2853
+ description: "Find files matching a glob pattern. Returns file paths. Pattern e.g. '**/*.md' or '*.ts'.",
2854
+ parameters: GlobParams,
2855
+ execute: async (_toolCallId, params) => {
2856
+ const baseDir = params.cwd ? resolvePath(params.cwd) : process.cwd();
2857
+ const results = [];
2858
+ await globWalk(baseDir, params.pattern, results, baseDir);
2859
+ const limited = results.slice(0, 200);
2860
+ return {
2861
+ content: [{
2862
+ type: "text",
2863
+ text: limited.length > 0 ? limited.join("\n") : "No files matched the pattern."
2864
+ }],
2865
+ details: {
2866
+ paths: limited,
2867
+ count: results.length
2868
+ }
2869
+ };
2870
+ }
2871
+ };
2872
+ const LsParams = Type.Object({ path: Type.String({ description: "Directory path" }) });
2873
+ const lsTool = {
2874
+ name: "ls",
2875
+ label: "List Directory",
2876
+ description: "List directory contents with file types and sizes. Path can be absolute or ~/...",
2877
+ parameters: LsParams,
2878
+ execute: async (_toolCallId, params) => {
2879
+ const resolved = resolvePath(params.path);
2880
+ try {
2881
+ const entries = await fs.readdir(resolved, { withFileTypes: true });
2882
+ const items = [];
2883
+ for (const e of entries) {
2884
+ const item = {
2885
+ name: e.name,
2886
+ type: e.isDirectory() ? "dir" : "file"
2887
+ };
2888
+ if (e.isFile()) try {
2889
+ item.size = (await fs.stat(path.join(resolved, e.name))).size;
2890
+ } catch {}
2891
+ items.push(item);
2892
+ }
2893
+ return {
2894
+ content: [{
2895
+ type: "text",
2896
+ text: items.map((i) => `${i.type === "dir" ? "📁" : "📄"} ${i.name}${i.size != null ? ` (${i.size}B)` : ""}`).join("\n")
2897
+ }],
2898
+ details: {
2899
+ path: resolved,
2900
+ entries: items
2901
+ }
2902
+ };
2903
+ } catch (err) {
2904
+ const msg = err instanceof Error ? err.message : String(err);
2905
+ return {
2906
+ content: [{
2907
+ type: "text",
2908
+ text: `Error: ${msg}`
2909
+ }],
2910
+ details: {
2911
+ error: msg,
2912
+ path: resolved
2913
+ }
2914
+ };
2915
+ }
2916
+ }
2917
+ };
2918
+ const BashParams = Type.Object({
2919
+ command: Type.String({ description: "Shell command to execute" }),
2920
+ cwd: Type.Optional(Type.String({ description: "Working directory" }))
2921
+ });
2922
+ const bashTool = {
2923
+ name: "bash",
2924
+ label: "Shell",
2925
+ description: "Execute a shell command. Use for checking system state, running pai commands, etc. Timeout: 30s. Output truncated to 10KB.",
2926
+ parameters: BashParams,
2927
+ execute: async (_toolCallId, params) => {
2928
+ const workDir = params.cwd ? resolvePath(params.cwd) : process.cwd();
2929
+ return new Promise((resolve) => {
2930
+ execFile("bash", ["-c", params.command], {
2931
+ encoding: "utf-8",
2932
+ timeout: BASH_TIMEOUT_MS,
2933
+ maxBuffer: BASH_OUTPUT_MAX_BYTES,
2934
+ cwd: workDir
2935
+ }, (err, stdout, stderr) => {
2936
+ if (err) {
2937
+ resolve({
2938
+ content: [{
2939
+ type: "text",
2940
+ text: [
2941
+ stdout ? `stdout: ${stdout.slice(0, 2e3)}` : "",
2942
+ stderr ? `stderr: ${stderr.slice(0, 2e3)}` : "",
2943
+ `error: ${err.message}`
2944
+ ].filter(Boolean).join("\n")
2945
+ }],
2946
+ details: {
2947
+ exitCode: err.code ?? -1,
2948
+ error: err.message
2949
+ }
2950
+ });
2951
+ return;
2952
+ }
2953
+ const output = (stdout ?? "").slice(0, BASH_OUTPUT_MAX_BYTES);
2954
+ const stderrTrimmed = (stderr ?? "").trim();
2955
+ resolve({
2956
+ content: [{
2957
+ type: "text",
2958
+ text: stderrTrimmed ? `${output}\nstderr: ${stderrTrimmed}` : output
2959
+ }],
2960
+ details: { exitCode: 0 }
2961
+ });
2962
+ });
2963
+ });
2964
+ }
2965
+ };
2966
+
2967
+ //#endregion
2968
+ //#region src/ask/tools-connector.ts
2969
+ /**
2970
+ * Try to extract a meaningful content date from markdown body.
2971
+ * Checks common field patterns across connector types:
2972
+ * **Time**: ... (calendar events)
2973
+ * **Date**: ... (emails, generic)
2974
+ * **Created**: ...
2975
+ * Falls back to frontmatter timestamp (import time).
2976
+ */
2977
+ function extractContentDate(content, frontmatter) {
2978
+ const patterns = [
2979
+ /\*\*Time\*\*:\s*(\S+)/,
2980
+ /\*\*Date\*\*:\s*(.+)/,
2981
+ /\*\*Created\*\*:\s*(.+)/
2982
+ ];
2983
+ for (const re of patterns) {
2984
+ const m = content.match(re);
2985
+ if (m) {
2986
+ const raw = re === patterns[0] ? m[1] : m[1].trim();
2987
+ const d = new Date(raw);
2988
+ if (!isNaN(d.getTime())) return d;
2989
+ }
2990
+ }
2991
+ if (typeof frontmatter.timestamp === "string") {
2992
+ const d = new Date(frontmatter.timestamp);
2993
+ if (!isNaN(d.getTime())) return d;
2994
+ }
2995
+ return null;
2996
+ }
2997
+ /** Extract title from "# Title" in markdown content */
2998
+ function extractTitle(content) {
2999
+ const m = content.match(/^#\s+(.+)/m);
3000
+ return m ? m[1].trim() : "(no title)";
3001
+ }
3002
+ /** Extract all "- **Field**: value" pairs from markdown content */
3003
+ function extractFields(content) {
3004
+ const fields = {};
3005
+ const re = /\*\*(\w[\w\s]*?)\*\*:\s*(.+)/g;
3006
+ let match;
3007
+ while ((match = re.exec(content)) !== null) fields[match[1].trim()] = match[2].trim();
3008
+ return fields;
3009
+ }
3010
+ /** Read and parse all markdown files in a connector subdirectory */
3011
+ async function readConnectorDir(subdir) {
3012
+ const dir = path.join(getPaiHome(), "raw", "connector", subdir);
3013
+ let fileNames;
3014
+ try {
3015
+ fileNames = (await fs.readdir(dir)).filter((f) => f.endsWith(".md"));
3016
+ } catch {
3017
+ return [];
3018
+ }
3019
+ const results = [];
3020
+ const BATCH = 50;
3021
+ for (let i = 0; i < fileNames.length; i += BATCH) {
3022
+ const batch = fileNames.slice(i, i + BATCH);
3023
+ const parsed = await Promise.all(batch.map(async (f) => {
3024
+ const filePath = path.join(dir, f);
3025
+ try {
3026
+ const { data, content } = matter(await fs.readFile(filePath, "utf-8"));
3027
+ const fm = data;
3028
+ return {
3029
+ file: filePath,
3030
+ title: extractTitle(content),
3031
+ date: extractContentDate(content, fm),
3032
+ fields: extractFields(content),
3033
+ frontmatter: fm
3034
+ };
3035
+ } catch {
3036
+ return null;
3037
+ }
3038
+ }));
3039
+ for (const p of parsed) if (p) results.push(p);
3040
+ }
3041
+ return results;
3042
+ }
3043
+ const QueryConnectorParams = Type.Object({
3044
+ source: Type.Optional(Type.String({ description: "Connector source to query (e.g. 'calendar', 'gmail', 'mac'). Omit to list all available sources." })),
3045
+ range: Type.Optional(Type.Union([
3046
+ Type.Literal("upcoming"),
3047
+ Type.Literal("today"),
3048
+ Type.Literal("this_week"),
3049
+ Type.Literal("past_week"),
3050
+ Type.Literal("past_month"),
3051
+ Type.Literal("all")
3052
+ ], { description: "Time range filter. 'upcoming' = future events only, 'today' = today, 'this_week' = ±7 days, 'past_week' = last 7 days, 'past_month' = last 30 days, 'all' = no filter. Default: 'all'." })),
3053
+ limit: Type.Optional(Type.Number({ description: "Max entries to return (default 10)" })),
3054
+ query: Type.Optional(Type.String({ description: "Optional text filter on title/content (case-insensitive)" })),
3055
+ sort: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")], { description: "Sort by date: 'asc' = oldest first (good for upcoming events), 'desc' = newest first (good for emails). Default: auto-detected based on range." }))
3056
+ });
3057
+ const queryConnectorTool = {
3058
+ name: "queryConnector",
3059
+ label: "Query Connector Data",
3060
+ description: "Query imported data from any connector source (calendar, gmail, mac, etc.). Call WITHOUT 'source' to discover available connectors. Call WITH 'source' to query entries sorted by date. Supports time range filtering and text search. Use this for ALL time-sensitive questions (schedules, recent emails, upcoming events).",
3061
+ parameters: QueryConnectorParams,
3062
+ execute: async (_toolCallId, params) => {
3063
+ const connectorDir = path.join(getPaiHome(), "raw", "connector");
3064
+ if (!params.source) {
3065
+ let subdirs;
3066
+ try {
3067
+ subdirs = (await fs.readdir(connectorDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
3068
+ } catch {
3069
+ return {
3070
+ content: [{
3071
+ type: "text",
3072
+ text: "No connector data found. Run 'pai import' to import data."
3073
+ }],
3074
+ details: { sources: [] }
3075
+ };
3076
+ }
3077
+ if (subdirs.length === 0) return {
3078
+ content: [{
3079
+ type: "text",
3080
+ text: "No connector sources found. Run 'pai import --source <source>' to import data."
3081
+ }],
3082
+ details: { sources: [] }
3083
+ };
3084
+ const summaries = [];
3085
+ for (const sub of subdirs) {
3086
+ const subPath = path.join(connectorDir, sub);
3087
+ try {
3088
+ const files = (await fs.readdir(subPath)).filter((f) => f.endsWith(".md"));
3089
+ let sample = "";
3090
+ if (files.length > 0) try {
3091
+ const latest = path.join(subPath, files[files.length - 1]);
3092
+ const { content } = matter(await fs.readFile(latest, "utf-8"));
3093
+ sample = extractTitle(content);
3094
+ } catch {}
3095
+ summaries.push({
3096
+ name: sub,
3097
+ files: files.length,
3098
+ sample
3099
+ });
3100
+ } catch {
3101
+ summaries.push({
3102
+ name: sub,
3103
+ files: 0,
3104
+ sample: ""
3105
+ });
3106
+ }
3107
+ }
3108
+ return {
3109
+ content: [{
3110
+ type: "text",
3111
+ text: `Available connector sources:\n${summaries.map((s) => `- **${s.name}**: ${s.files} files${s.sample ? ` (latest: "${s.sample}")` : ""}`).join("\n")}\n\nCall queryConnector with source="<name>" to query a specific source.`
3112
+ }],
3113
+ details: { sources: summaries.map((s) => ({
3114
+ name: s.name,
3115
+ files: s.files
3116
+ })) }
3117
+ };
3118
+ }
3119
+ const entries = await readConnectorDir(params.source);
3120
+ if (entries.length === 0) return {
3121
+ content: [{
3122
+ type: "text",
3123
+ text: `No data found for source "${params.source}". Check available sources by calling queryConnector without a source parameter.`
3124
+ }],
3125
+ details: {
3126
+ results: [],
3127
+ totalImported: 0
3128
+ }
3129
+ };
3130
+ const now = /* @__PURE__ */ new Date();
3131
+ const range = params.range ?? "all";
3132
+ const limit = params.limit ?? 10;
3133
+ const queryLower = params.query?.toLowerCase();
3134
+ const filtered = entries.filter((e) => {
3135
+ if (queryLower) {
3136
+ if (!(e.title + " " + Object.values(e.fields).join(" ")).toLowerCase().includes(queryLower)) return false;
3137
+ }
3138
+ if (range !== "all") {
3139
+ if (!e.date) return false;
3140
+ const t = e.date.getTime();
3141
+ const nowMs = now.getTime();
3142
+ const DAY = 1440 * 60 * 1e3;
3143
+ switch (range) {
3144
+ case "upcoming":
3145
+ if (t < nowMs) return false;
3146
+ break;
3147
+ case "today": {
3148
+ const startOfDay = new Date(now);
3149
+ startOfDay.setHours(0, 0, 0, 0);
3150
+ const endOfDay = new Date(now);
3151
+ endOfDay.setHours(23, 59, 59, 999);
3152
+ if (t < startOfDay.getTime() || t > endOfDay.getTime()) return false;
3153
+ break;
3154
+ }
3155
+ case "this_week":
3156
+ if (t < nowMs - 7 * DAY || t > nowMs + 7 * DAY) return false;
3157
+ break;
3158
+ case "past_week":
3159
+ if (t < nowMs - 7 * DAY || t > nowMs) return false;
3160
+ break;
3161
+ case "past_month":
3162
+ if (t < nowMs - 30 * DAY || t > nowMs) return false;
3163
+ break;
3164
+ }
3165
+ }
3166
+ return true;
3167
+ });
3168
+ const sortDir = params.sort ?? (range === "upcoming" || range === "today" || range === "this_week" ? "asc" : "desc");
3169
+ filtered.sort((a, b) => {
3170
+ const ta = a.date?.getTime() ?? 0;
3171
+ const tb = b.date?.getTime() ?? 0;
3172
+ return sortDir === "asc" ? ta - tb : tb - ta;
3173
+ });
3174
+ const limited = filtered.slice(0, limit);
3175
+ if (limited.length === 0) return {
3176
+ content: [{
3177
+ type: "text",
3178
+ text: `No entries matched for source "${params.source}", range "${range}". Total imported: ${entries.length}`
3179
+ }],
3180
+ details: {
3181
+ results: [],
3182
+ totalImported: entries.length
3183
+ }
3184
+ };
3185
+ const text = limited.map((e, i) => {
3186
+ const parts = [`${i + 1}. **${e.title}**`];
3187
+ for (const [key, val] of Object.entries(e.fields)) parts.push(` ${key}: ${val}`);
3188
+ if (e.date && !e.fields["Time"] && !e.fields["Date"]) parts.push(` Date: ${e.date.toISOString()}`);
3189
+ return parts.join("\n");
3190
+ }).join("\n\n");
3191
+ return {
3192
+ content: [{
3193
+ type: "text",
3194
+ text: `Found ${filtered.length} entries (showing ${limited.length}) from "${params.source}":\n\n${text}`
3195
+ }],
3196
+ details: {
3197
+ results: limited.map((e) => ({
3198
+ file: e.file,
3199
+ title: e.title,
3200
+ date: e.date?.toISOString() ?? null,
3201
+ fields: e.fields
3202
+ })),
3203
+ totalMatched: filtered.length,
3204
+ totalImported: entries.length
3205
+ }
3206
+ };
3207
+ }
3208
+ };
3209
+
3210
+ //#endregion
3211
+ //#region src/ask/tools-pai.ts
3212
+ const SearchParams = Type.Object({
3213
+ query: Type.String({ description: "Search query" }),
3214
+ limit: Type.Optional(Type.Number({ description: "Max results (default 5)" }))
3215
+ });
3216
+ const searchVaultTool = {
3217
+ name: "searchVault",
3218
+ label: "Search Vault",
3219
+ description: "Search user's distilled knowledge vault (experiences, preferences, lessons). Hybrid search: BM25 + vector + reranking. Ranked by RELEVANCE, not recency. For time-sensitive queries, supplement with bash/grep on raw files.",
3220
+ parameters: SearchParams,
3221
+ execute: async (_toolCallId, params) => {
3222
+ if (!await isQmdAvailable()) return {
3223
+ content: [{
3224
+ type: "text",
3225
+ text: "Error: QMD not installed."
3226
+ }],
3227
+ details: { error: "QMD not installed" }
3228
+ };
3229
+ try {
3230
+ const results = await search(params.query, "vault", params.limit ?? 5, "query");
3231
+ return {
3232
+ content: [{
3233
+ type: "text",
3234
+ text: results.map((r) => `## ${r.title ?? "Untitled"}\nFile: ${r.file}\n${r.snippet ?? ""}`).join("\n\n") || "No results found."
3235
+ }],
3236
+ details: { results: results.map((r) => ({
3237
+ file: r.file,
3238
+ title: r.title,
3239
+ snippet: r.snippet
3240
+ })) }
3241
+ };
3242
+ } catch (err) {
3243
+ const msg = err instanceof Error ? err.message : String(err);
3244
+ return {
3245
+ content: [{
3246
+ type: "text",
3247
+ text: `Error: ${msg}`
3248
+ }],
3249
+ details: { error: msg }
3250
+ };
3251
+ }
3252
+ }
3253
+ };
3254
+ const searchRawTool = {
3255
+ name: "searchRaw",
3256
+ label: "Search Raw",
3257
+ description: "Search user's original raw data (for tracing back to sources). Raw files have timestamps in frontmatter. Ranked by RELEVANCE, not recency.",
3258
+ parameters: SearchParams,
3259
+ execute: async (_toolCallId, params) => {
3260
+ if (!await isQmdAvailable()) return {
3261
+ content: [{
3262
+ type: "text",
3263
+ text: "Error: QMD not installed."
3264
+ }],
3265
+ details: { error: "QMD not installed" }
3266
+ };
3267
+ try {
3268
+ const results = await search(params.query, "raw", params.limit ?? 5, "query");
3269
+ return {
3270
+ content: [{
3271
+ type: "text",
3272
+ text: results.map((r) => `## ${r.title ?? "Untitled"}\nFile: ${r.file}\n${r.snippet ?? ""}`).join("\n\n") || "No results found."
3273
+ }],
3274
+ details: { results: results.map((r) => ({
3275
+ file: r.file,
3276
+ title: r.title,
3277
+ snippet: r.snippet
3278
+ })) }
3279
+ };
3280
+ } catch (err) {
3281
+ const msg = err instanceof Error ? err.message : String(err);
3282
+ return {
3283
+ content: [{
3284
+ type: "text",
3285
+ text: `Error: ${msg}`
3286
+ }],
3287
+ details: { error: msg }
3288
+ };
3289
+ }
3290
+ }
3291
+ };
3292
+ const EmptyParams = Type.Object({});
3293
+ const readProfileTool = {
3294
+ name: "readProfile",
3295
+ label: "Read Profile",
3296
+ description: "Read the user's compiled profile (identity, environment, tools, projects, habits). This is the fastest way to understand who the user is.",
3297
+ parameters: EmptyParams,
3298
+ execute: async () => {
3299
+ const profile = await loadProfile();
3300
+ return {
3301
+ content: [{
3302
+ type: "text",
3303
+ text: profile ?? "No profile found. Run 'pai init' first."
3304
+ }],
3305
+ details: { hasProfile: !!profile }
3306
+ };
3307
+ }
3308
+ };
3309
+
3310
+ //#endregion
3311
+ //#region src/ask/tools.ts
3312
+ /** Create the full ask agent tool set: pai knowledge tools + filesystem meta tools */
3313
+ function createAskTools(opts) {
3314
+ const tools = [
3315
+ searchVaultTool,
3316
+ searchRawTool,
3317
+ readProfileTool,
3318
+ queryConnectorTool,
3319
+ readFileTool,
3320
+ grepTool,
3321
+ globTool,
3322
+ lsTool
3323
+ ];
3324
+ if (opts?.allowBash) tools.push(bashTool);
3325
+ return tools;
3326
+ }
3327
+
3328
+ //#endregion
3329
+ //#region src/ask/agent.ts
3330
+ /**
3331
+ * Resolve a model from pai config.
3332
+ * - If custom baseUrl is set, construct an openai-completions Model for compatibility.
3333
+ * - Otherwise try the pi-ai registry first, then fallback to a manual Model.
3334
+ */
3335
+ function resolveModel(modelId, baseUrl) {
3336
+ const trimmedBase = baseUrl?.trim() || void 0;
3337
+ if (trimmedBase) return {
3338
+ id: modelId,
3339
+ name: modelId,
3340
+ api: "openai-completions",
3341
+ provider: "openai",
3342
+ baseUrl: trimmedBase,
3343
+ reasoning: false,
3344
+ input: ["text"],
3345
+ cost: {
3346
+ input: 0,
3347
+ output: 0,
3348
+ cacheRead: 0,
3349
+ cacheWrite: 0
3350
+ },
3351
+ contextWindow: 128e3,
3352
+ maxTokens: 16384
3353
+ };
3354
+ const registered = getModel("openai", modelId);
3355
+ if (registered) return registered;
3356
+ return {
3357
+ id: modelId,
3358
+ name: modelId,
3359
+ api: "openai-completions",
3360
+ provider: "openai",
3361
+ baseUrl: "https://api.openai.com/v1",
3362
+ reasoning: false,
3363
+ input: ["text"],
3364
+ cost: {
3365
+ input: 0,
3366
+ output: 0,
3367
+ cacheRead: 0,
3368
+ cacheWrite: 0
3369
+ },
3370
+ contextWindow: 128e3,
3371
+ maxTokens: 16384
3372
+ };
3373
+ }
3374
+ /** Build full system prompt with user preferences injected */
3375
+ async function getSystemPrompt() {
3376
+ const base = buildAskSystemPrompt();
3377
+ try {
3378
+ const prefs = await fs.readFile(getPreferencesPath(), "utf-8");
3379
+ if (prefs.trim()) return `${base}\n\n---USER PREFERENCES (always respect these)---\n${prefs}`;
3380
+ } catch {}
3381
+ return base;
3382
+ }
3383
+ /** Knowledge tool names whose results count as citable sources */
3384
+ const KNOWLEDGE_TOOLS = new Set([
3385
+ "searchVault",
3386
+ "searchRaw",
3387
+ "readProfile",
3388
+ "readFile",
3389
+ "queryConnector"
3390
+ ]);
3391
+ /** Extract source file paths from pai knowledge tool results only (skip glob/ls/bash noise) */
3392
+ function extractSources(messages) {
3393
+ const sources = /* @__PURE__ */ new Set();
3394
+ for (const msg of messages) {
3395
+ if (msg.role !== "toolResult") continue;
3396
+ if (!KNOWLEDGE_TOOLS.has(msg.toolName)) continue;
3397
+ const details = msg.details;
3398
+ if (!details) continue;
3399
+ if (Array.isArray(details.results)) {
3400
+ for (const r of details.results) if (typeof r.file === "string" && r.file) sources.add(r.file);
3401
+ }
3402
+ if (typeof details.path === "string" && details.path) sources.add(details.path);
3403
+ }
3404
+ return [...sources];
3405
+ }
3406
+ /** Aggregate token usage from all assistant messages */
3407
+ function aggregateUsage(messages) {
3408
+ const usage = {
3409
+ inputTokens: 0,
3410
+ outputTokens: 0,
3411
+ cacheReadTokens: 0,
3412
+ cacheWriteTokens: 0,
3413
+ totalTokens: 0,
3414
+ cost: 0
3415
+ };
3416
+ for (const msg of messages) if (msg.role === "assistant" && msg.usage) {
3417
+ const u = msg.usage;
3418
+ usage.inputTokens += u.input ?? 0;
3419
+ usage.outputTokens += u.output ?? 0;
3420
+ usage.cacheReadTokens += u.cacheRead ?? 0;
3421
+ usage.cacheWriteTokens += u.cacheWrite ?? 0;
3422
+ usage.totalTokens += u.totalTokens ?? 0;
3423
+ if (u.cost) usage.cost += u.cost.total ?? 0;
3424
+ }
3425
+ return usage;
3426
+ }
3427
+ /** Extract text answer from the last assistant message */
3428
+ function extractAnswer(messages) {
3429
+ for (let i = messages.length - 1; i >= 0; i--) {
3430
+ const msg = messages[i];
3431
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
3432
+ const texts = [];
3433
+ for (const c of msg.content) if (c.type === "text") texts.push(c.text);
3434
+ if (texts.length > 0) return texts.join("");
3435
+ }
3436
+ }
3437
+ return "";
3438
+ }
3439
+ async function runAskAgent(question, opts) {
3440
+ const config = await loadConfig();
3441
+ const apiKey = process.env[config.llm.apiKeyEnv];
3442
+ if (!apiKey) throw new Error(`Missing API key: set ${config.llm.apiKeyEnv} environment variable`);
3443
+ const modelId = opts?.model ?? config.llm.cheapModel;
3444
+ const model = resolveModel(modelId, config.llm.baseUrl);
3445
+ const agent = new Agent({
3446
+ initialState: {
3447
+ systemPrompt: await getSystemPrompt(),
3448
+ model,
3449
+ tools: createAskTools({ allowBash: opts?.allowBash })
3450
+ },
3451
+ getApiKey: async () => apiKey
3452
+ });
3453
+ const maxSteps = opts?.maxSteps ?? 10;
3454
+ let turnCount = 0;
3455
+ let toolCallCount = 0;
3456
+ const unsubscribe = agent.subscribe((event) => {
3457
+ if (event.type === "turn_end") {
3458
+ turnCount++;
3459
+ if (turnCount >= maxSteps) agent.abort();
3460
+ }
3461
+ if (event.type === "tool_execution_start") toolCallCount++;
3462
+ opts?.onEvent?.(event);
3463
+ });
3464
+ const startTime = performance.now();
3465
+ try {
3466
+ await agent.prompt(question);
3467
+ } finally {
3468
+ unsubscribe();
3469
+ }
3470
+ const durationMs = Math.round(performance.now() - startTime);
3471
+ const messages = agent.state.messages;
3472
+ const answer = extractAnswer(messages);
3473
+ const sources = extractSources(messages);
3474
+ const usage = aggregateUsage(messages);
3475
+ let actualModel = modelId;
3476
+ let stopReason = "unknown";
3477
+ for (let i = messages.length - 1; i >= 0; i--) {
3478
+ const m = messages[i];
3479
+ if (m.role === "assistant") {
3480
+ if (m.model) actualModel = m.model;
3481
+ if (m.stopReason) stopReason = m.stopReason;
3482
+ break;
3483
+ }
3484
+ }
3485
+ return {
3486
+ answer,
3487
+ sources,
3488
+ steps: turnCount,
3489
+ durationMs,
3490
+ usage,
3491
+ model: actualModel,
3492
+ toolCalls: toolCallCount,
3493
+ stopReason
3494
+ };
3495
+ }
3496
+
3497
+ //#endregion
3498
+ //#region src/cli/register.ask.ts
3499
+ function registerAskCommand(program) {
3500
+ program.command("ask <question>").description("Ask a question about the user; agent uses tools (search, read, bash) to find the answer").option("--json", "Output as JSON (answer, sources, steps)").option("--steps <n>", "Max tool-call steps", "10").option("--model <name>", "Override LLM model").option("--verbose", "Show each tool step").option("--unsafe-bash", "Enable bash tool (risk: model can execute arbitrary commands)").action(async (question, opts) => {
3501
+ if (!question?.trim()) {
3502
+ error("Usage: pai ask <question>");
3503
+ process.exit(1);
3504
+ }
3505
+ try {
3506
+ const onEvent = opts.verbose ? (event) => {
3507
+ if (event.type === "tool_execution_start") log(`[tool] ${event.toolName}(${JSON.stringify(event.args ?? {}).slice(0, 80)}...)`);
3508
+ if (event.type === "tool_execution_end") {
3509
+ const status = event.isError ? "✗" : "✓";
3510
+ log(`[tool] ${event.toolName} ${status}`);
3511
+ }
3512
+ } : void 0;
3513
+ const result = await runAskAgent(question.trim(), {
3514
+ maxSteps: parseInt(String(opts.steps), 10) || 10,
3515
+ model: opts.model,
3516
+ allowBash: opts.unsafeBash,
3517
+ onEvent
3518
+ });
3519
+ if (opts.json) process.stdout.write(JSON.stringify({
3520
+ answer: result.answer,
3521
+ sources: result.sources,
3522
+ model: result.model,
3523
+ steps: result.steps,
3524
+ toolCalls: result.toolCalls,
3525
+ stopReason: result.stopReason,
3526
+ durationMs: result.durationMs,
3527
+ usage: result.usage
3528
+ }, null, 2) + "\n");
3529
+ else {
3530
+ process.stdout.write(result.answer + "\n");
3531
+ if (result.sources.length > 0) process.stdout.write("\n_Sources: " + result.sources.join(", ") + "_\n");
3532
+ const { usage, durationMs, steps, model, toolCalls } = result;
3533
+ const secs = (durationMs / 1e3).toFixed(1);
3534
+ const tps = durationMs > 0 ? (usage.outputTokens / durationMs * 1e3).toFixed(1) : "–";
3535
+ const costStr = usage.cost > 0 ? `$${usage.cost.toFixed(4)}` : "";
3536
+ const cacheStr = usage.cacheReadTokens > 0 ? ` · cache: ${usage.cacheReadTokens} read` : "";
3537
+ const parts = [
3538
+ model,
3539
+ `${secs}s`,
3540
+ `${steps} steps · ${toolCalls} tool calls`,
3541
+ `${usage.totalTokens} tokens (in: ${usage.inputTokens}, out: ${usage.outputTokens})`,
3542
+ `${tps} tok/s`
3543
+ ];
3544
+ if (cacheStr) parts.push(cacheStr.replace(" · ", ""));
3545
+ if (costStr) parts.push(costStr);
3546
+ process.stdout.write(`\n---\n${parts.join(" · ")}\n`);
3547
+ }
3548
+ } catch (err) {
3549
+ const msg = err instanceof Error ? err.message : String(err);
3550
+ error(`Ask failed: ${msg}`);
3551
+ process.exit(1);
3552
+ }
3553
+ });
3554
+ }
3555
+
3556
+ //#endregion
3557
+ //#region src/cli/register.context.ts
3558
+ function registerContextCommand(program) {
3559
+ program.command("context").description("Output personal context for AI agents (identity + task-relevant memories)").option("--task <description>", "Current task — searches vault for relevant memories").option("--profile <name>", "Profile to use", "full-context").option("--json", "Output as JSON for machine consumption").action(async (opts) => {
3560
+ try {
3561
+ const profileName = opts.profile ?? "full-context";
3562
+ const profileContent = await readProfile(profileName);
3563
+ if (!profileContent) {
3564
+ error(`Profile "${profileName}" not found. Run "pai generate" first.`);
3565
+ process.exit(1);
3566
+ }
3567
+ const identity = extractCompactIdentity(profileContent);
3568
+ let memories = [];
3569
+ if (opts.task) {
3570
+ if (await isQmdAvailable()) memories = await search(opts.task, "vault", 5, "query");
3571
+ }
3572
+ if (opts.json) {
3573
+ const output = {
3574
+ profile: profileName,
3575
+ identity,
3576
+ memories: memories.map((m) => ({
3577
+ file: m.file,
3578
+ title: m.title ?? null,
3579
+ snippet: m.snippet,
3580
+ score: m.score ?? null
3581
+ }))
3582
+ };
3583
+ process.stdout.write(JSON.stringify(output, null, 2) + "\n");
3584
+ } else {
3585
+ process.stdout.write(identity + "\n");
3586
+ if (memories.length > 0) {
3587
+ process.stdout.write("\n## Relevant Memories\n\n");
3588
+ for (const m of memories) {
3589
+ const title = m.title ?? path.basename(m.file);
3590
+ const snippet = m.snippet.slice(0, 160).replace(/\n/g, " ");
3591
+ process.stdout.write(`- **${title}**: ${snippet}\n`);
3592
+ process.stdout.write(` _source: ${m.file}_\n\n`);
3593
+ }
3594
+ } else if (opts.task) process.stdout.write("\n_No matching memories found for this task._\n");
3595
+ }
3596
+ } catch (err) {
3597
+ const msg = err instanceof Error ? err.message : String(err);
3598
+ error(`Context failed: ${msg}`);
3599
+ process.exit(1);
3600
+ }
3601
+ });
3602
+ }
3603
+ /** Read a profile file from ~/.pai/skills/profiles/ */
3604
+ async function readProfile(name) {
3605
+ const profilePath = path.join(getSkillsDir(), `${name}.md`);
3606
+ try {
3607
+ return await fs.readFile(profilePath, "utf-8");
3608
+ } catch {
3609
+ return null;
3610
+ }
3611
+ }
3612
+ /**
3613
+ * Extract compact identity from a full profile.
3614
+ * Keeps: ## Who I Am, ## Preferences, ## Current Work sections
3615
+ * Drops: ## Hard-won Lessons (those come from search results instead)
3616
+ */
3617
+ function extractCompactIdentity(profile) {
3618
+ const lines = profile.split("\n");
3619
+ const sections = [];
3620
+ let current = null;
3621
+ for (const line of lines) if (line.startsWith("## ")) {
3622
+ if (current) sections.push(current);
3623
+ current = {
3624
+ heading: line,
3625
+ lines: []
3626
+ };
3627
+ } else if (line.startsWith("# ") && !line.startsWith("## ")) {
3628
+ if (current) sections.push(current);
3629
+ current = {
3630
+ heading: line,
3631
+ lines: []
3632
+ };
3633
+ } else if (current) current.lines.push(line);
3634
+ if (current) sections.push(current);
3635
+ return sections.filter((s) => !s.heading.toLowerCase().includes("hard-won lessons")).map((s) => [s.heading, ...s.lines].join("\n")).join("\n");
3636
+ }
3637
+
3638
+ //#endregion
3639
+ //#region src/cli/register.distribute.ts
3640
+ const MANAGED_TAG = "<!-- managed by pai distribute — do not edit manually -->";
3641
+ function registerDistributeCommand(program) {
3642
+ program.command("distribute").description("Deploy personal profile + agent instructions to Cursor rules and other agent configs").option("--target <name>", "Target platform (cursor). Default: all").option("--profile <name>", "Legacy: use a generated SKILL.md profile instead of profile.md").action(async (opts) => {
3643
+ try {
3644
+ let profileContent = null;
3645
+ let source = "";
3646
+ if (opts.profile) {
3647
+ profileContent = await readLegacyProfile(opts.profile);
3648
+ source = `skills/profiles/${opts.profile}.md`;
3649
+ } else {
3650
+ profileContent = await loadProfile();
3651
+ source = "profile.md";
3652
+ if (!profileContent) {
3653
+ profileContent = await readLegacyProfile("full-context");
3654
+ source = "skills/profiles/full-context.md";
3655
+ }
3656
+ }
3657
+ if (!profileContent) {
3658
+ error("No profile found. Run \"pai init\" or \"pai profile --rebuild\" first.");
3659
+ process.exit(1);
3660
+ }
3661
+ info(`Source: ${source}`);
3662
+ const ruleContent = buildAgentRule(profileContent);
3663
+ const targets = opts.target ? [opts.target] : ["cursor"];
3664
+ const results = [];
3665
+ for (const target of targets) switch (target) {
3666
+ case "cursor": {
3667
+ const wrote = await distributeToCursor(ruleContent);
3668
+ if (wrote) results.push(wrote);
3669
+ break;
3670
+ }
3671
+ default: warn(`Unknown target: ${target}`);
3672
+ }
3673
+ if (results.length > 0) {
3674
+ success(`Distributed to ${results.length} target(s):`);
3675
+ for (const r of results) log(` → ${r}`);
3676
+ } else warn("No targets were written.");
3677
+ } catch (err) {
3678
+ const msg = err instanceof Error ? err.message : String(err);
3679
+ error(`Distribute failed: ${msg}`);
3680
+ process.exit(1);
3681
+ }
3682
+ });
3683
+ }
3684
+ /** Read a legacy SKILL.md profile file from skills/profiles/ */
3685
+ async function readLegacyProfile(name) {
3686
+ const profilePath = path.join(getSkillsDir(), `${name}.md`);
3687
+ try {
3688
+ return await fs.readFile(profilePath, "utf-8");
3689
+ } catch {
3690
+ return null;
3691
+ }
3692
+ }
3693
+ /**
3694
+ * Build the full agent rule file content.
3695
+ * Combines: user profile (who you are) + skill instructions (how to use pai).
3696
+ */
3697
+ function buildAgentRule(profileContent) {
3698
+ return `${MANAGED_TAG}
3699
+ # Personal AI Context
3700
+
3701
+ ${profileContent.trim()}
3702
+
3703
+ ---
3704
+
3705
+ ## Agent Skill — pai CLI
3706
+
3707
+ You have access to the user's personal knowledge base via \`pai\` CLI commands.
3708
+ Use these to retrieve context, search knowledge, and remember new lessons.
3709
+
3710
+ ### Retrieve task-relevant memories (RECOMMENDED before starting work)
3711
+
3712
+ \`\`\`bash
3713
+ pai context --task "<brief description of current task>"
3714
+ \`\`\`
3715
+
3716
+ This returns the user's identity + vault memories relevant to the task.
3717
+
3718
+ ### Search specific knowledge
3719
+
3720
+ \`\`\`bash
3721
+ pai search "<query>" # hybrid search (best quality, ~5s)
3722
+ pai search "<query>" --fast # keyword search (instant)
3723
+ pai search "<query>" --json # machine-readable output
3724
+ \`\`\`
3725
+
3726
+ ### Remember new lessons
3727
+
3728
+ When you discover something the user should remember (a lesson, preference, or tip):
3729
+
3730
+ \`\`\`bash
3731
+ pai add "<lesson or experience>"
3732
+ \`\`\`
3733
+
3734
+ ### Rebuild profile after changes
3735
+
3736
+ \`\`\`bash
3737
+ pai profile --rebuild # re-scan local machine and recompile
3738
+ pai distribute # redeploy updated profile
3739
+ \`\`\`
3740
+ `;
3741
+ }
3742
+ /** Deploy to ~/.cursor/rules/pai-context.mdc */
3743
+ async function distributeToCursor(content) {
3744
+ const cursorRulesDir = path.join(os.homedir(), ".cursor", "rules");
3745
+ const targetPath = path.join(cursorRulesDir, "pai-context.mdc");
3746
+ try {
3747
+ await fs.mkdir(cursorRulesDir, { recursive: true });
3748
+ await fs.writeFile(targetPath, content, "utf-8");
3749
+ return targetPath;
3750
+ } catch (err) {
3751
+ const msg = err instanceof Error ? err.message : String(err);
3752
+ warn(`Failed to write Cursor rule: ${msg}`);
3753
+ return null;
3754
+ }
3755
+ }
3756
+
3757
+ //#endregion
3758
+ //#region src/cli/register.profile.ts
3759
+ function registerProfileCommand(program) {
3760
+ program.command("profile").description("View, rebuild, or export your personal profile").option("--rebuild", "Re-scan local machine and rebuild profile").option("--export", "Output profile in copy-paste friendly format").option("--json", "Output profile metadata as JSON").action(async (opts) => {
3761
+ try {
3762
+ if (opts.rebuild) {
3763
+ const { profilePath, results } = await rebuildProfile({ verbose: true });
3764
+ success(`Profile rebuilt → ${profilePath}`);
3765
+ info(`Compiled from ${results.length} data sources.`);
3766
+ return;
3767
+ }
3768
+ const profile = await loadProfile();
3769
+ if (!profile) {
3770
+ error("No profile found. Run \"pai init\" or \"pai profile --rebuild\" first.");
3771
+ process.exit(1);
3772
+ }
3773
+ if (opts.json) {
3774
+ const lines = profile.split("\n");
3775
+ const sections = [];
3776
+ for (const line of lines) if (line.startsWith("## ")) sections.push({
3777
+ heading: line.replace("## ", ""),
3778
+ lineCount: 0
3779
+ });
3780
+ else if (sections.length > 0) sections[sections.length - 1].lineCount++;
3781
+ const meta = {
3782
+ path: getProfilePath(),
3783
+ totalLines: lines.length,
3784
+ sections
3785
+ };
3786
+ process.stdout.write(JSON.stringify(meta, null, 2) + "\n");
3787
+ return;
3788
+ }
3789
+ if (opts.export) {
3790
+ process.stdout.write(profile);
3791
+ return;
3792
+ }
3793
+ log("");
3794
+ info(`Profile: ${getProfilePath()}`);
3795
+ log("");
3796
+ process.stdout.write(profile);
3797
+ } catch (err) {
3798
+ const msg = err instanceof Error ? err.message : String(err);
3799
+ error(`Profile command failed: ${msg}`);
3800
+ process.exit(1);
3801
+ }
3802
+ });
3803
+ }
3804
+
3805
+ //#endregion
3806
+ //#region src/cli/command-registry.ts
3807
+ const commandRegistry = [
3808
+ {
3809
+ id: "auth",
3810
+ register: (p) => registerAuthCommand(p)
3811
+ },
3812
+ {
3813
+ id: "init",
3814
+ register: (p) => registerInitCommand(p)
3815
+ },
3816
+ {
3817
+ id: "reset",
3818
+ register: (p) => registerResetCommand(p)
3819
+ },
3820
+ {
3821
+ id: "add",
3822
+ register: (p) => registerAddCommand(p)
3823
+ },
3824
+ {
3825
+ id: "profile",
3826
+ register: (p) => registerProfileCommand(p)
3827
+ },
3828
+ {
3829
+ id: "distill",
3830
+ register: (p) => registerDistillCommand(p)
3831
+ },
3832
+ {
3833
+ id: "generate",
3834
+ register: (p) => registerGenerateCommand(p)
3835
+ },
3836
+ {
3837
+ id: "search",
3838
+ register: (p) => registerSearchCommand(p)
3839
+ },
3840
+ {
3841
+ id: "index",
3842
+ register: (p) => registerIndexCommand(p)
3843
+ },
3844
+ {
3845
+ id: "import",
3846
+ register: (p) => registerImportCommand(p)
3847
+ },
3848
+ {
3849
+ id: "status",
3850
+ register: (p) => registerStatusCommand(p)
3851
+ },
3852
+ {
3853
+ id: "context",
3854
+ register: (p) => registerContextCommand(p)
3855
+ },
3856
+ {
3857
+ id: "ask",
3858
+ register: (p) => registerAskCommand(p)
3859
+ },
3860
+ {
3861
+ id: "distribute",
3862
+ register: (p) => registerDistributeCommand(p)
3863
+ }
3864
+ ];
3865
+ function registerAllCommands(program) {
3866
+ for (const entry of commandRegistry) entry.register(program);
3867
+ }
3868
+
3869
+ //#endregion
3870
+ //#region src/cli/build-program.ts
3871
+ function buildProgram() {
3872
+ const program = new Command().name("pai").description("Personal AI Data Wallet — local-first experience manager").version("0.1.0");
3873
+ registerAllCommands(program);
3874
+ return program;
3875
+ }
3876
+
3877
+ //#endregion
3878
+ //#region src/entry.ts
3879
+ const program = buildProgram();
3880
+ process.on("uncaughtException", (error) => {
3881
+ console.error("[pai] Uncaught exception:", error.message);
3882
+ process.exit(1);
3883
+ });
3884
+ program.parseAsync(process.argv).catch((err) => {
3885
+ console.error("[pai] Error:", err.message);
3886
+ process.exit(1);
3887
+ });
3888
+
3889
+ //#endregion
3890
+ export { info as a, warn as c, bold as i, addConnectorEntry as n, log as o, googleOAuth as r, spinner as s, ALL_COLLECTORS as t };
3891
+ //# sourceMappingURL=entry.mjs.map