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/README.md +227 -0
- package/SKILL.md +310 -0
- package/dist/auth-Dtx8Wc3l.mjs +2 -0
- package/dist/calendar-BHcM4wfQ.mjs +91 -0
- package/dist/calendar-BHcM4wfQ.mjs.map +1 -0
- package/dist/entry.mjs +3891 -0
- package/dist/entry.mjs.map +1 -0
- package/dist/gmail-B9ja9sKN.mjs +92 -0
- package/dist/gmail-B9ja9sKN.mjs.map +1 -0
- package/dist/index.mjs +1761 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mac-C9SDXZGK.mjs +55 -0
- package/dist/mac-C9SDXZGK.mjs.map +1 -0
- package/package.json +72 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1761 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import fs from "node:fs/promises";
|
|
5
|
+
import JSON5 from "json5";
|
|
6
|
+
import matter from "gray-matter";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import OpenAI from "openai";
|
|
11
|
+
import { minimatch } from "minimatch";
|
|
12
|
+
|
|
13
|
+
//#region src/config/paths.ts
|
|
14
|
+
/** Root data directory for pai */
|
|
15
|
+
function getPaiHome() {
|
|
16
|
+
return process.env.PAI_HOME ?? path.join(os.homedir(), ".pai");
|
|
17
|
+
}
|
|
18
|
+
function getRawDir() {
|
|
19
|
+
return path.join(getPaiHome(), "raw");
|
|
20
|
+
}
|
|
21
|
+
function getVaultDir() {
|
|
22
|
+
return path.join(getPaiHome(), "vault");
|
|
23
|
+
}
|
|
24
|
+
function getSkillsDir() {
|
|
25
|
+
return path.join(getPaiHome(), "skills", "profiles");
|
|
26
|
+
}
|
|
27
|
+
function getConfigDir() {
|
|
28
|
+
return path.join(getPaiHome(), "config");
|
|
29
|
+
}
|
|
30
|
+
function getConfigPath() {
|
|
31
|
+
return path.join(getConfigDir(), "pai.json5");
|
|
32
|
+
}
|
|
33
|
+
function getProfilesPath() {
|
|
34
|
+
return path.join(getConfigDir(), "profiles.json5");
|
|
35
|
+
}
|
|
36
|
+
function getPreferencesPath() {
|
|
37
|
+
return path.join(getConfigDir(), "preferences.md");
|
|
38
|
+
}
|
|
39
|
+
/** Compiled user profile — the core output of pai */
|
|
40
|
+
function getProfilePath() {
|
|
41
|
+
return path.join(getPaiHome(), "profile.md");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/config/schema.ts
|
|
46
|
+
const QmdConfigSchema = z.object({
|
|
47
|
+
rawCollection: z.string().default("raw"),
|
|
48
|
+
vaultCollection: z.string().default("vault")
|
|
49
|
+
});
|
|
50
|
+
const LlmConfigSchema = z.object({
|
|
51
|
+
apiKeyEnv: z.string().default("OPENAI_API_KEY"),
|
|
52
|
+
baseUrl: z.string().optional(),
|
|
53
|
+
cheapModel: z.string().default("gpt-4o-mini"),
|
|
54
|
+
expensiveModel: z.string().default("gpt-4o")
|
|
55
|
+
});
|
|
56
|
+
const ScraperConfigSchema = z.object({ timeout: z.number().default(3e4) });
|
|
57
|
+
const VaultConfigSchema = z.object({
|
|
58
|
+
maxFileTokens: z.number().default(4e3),
|
|
59
|
+
warnFileTokens: z.number().default(3e3)
|
|
60
|
+
});
|
|
61
|
+
const PaiConfigSchema = z.object({
|
|
62
|
+
version: z.string().default("0.1"),
|
|
63
|
+
qmd: QmdConfigSchema.default(() => QmdConfigSchema.parse({})),
|
|
64
|
+
llm: LlmConfigSchema.default(() => LlmConfigSchema.parse({})),
|
|
65
|
+
scraper: ScraperConfigSchema.default(() => ScraperConfigSchema.parse({})),
|
|
66
|
+
vault: VaultConfigSchema.default(() => VaultConfigSchema.parse({}))
|
|
67
|
+
}).strict();
|
|
68
|
+
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/config/io.ts
|
|
71
|
+
/** Load and validate pai.json5 config */
|
|
72
|
+
async function loadConfig() {
|
|
73
|
+
const configPath = getConfigPath();
|
|
74
|
+
try {
|
|
75
|
+
const raw = await fs.readFile(configPath, "utf-8");
|
|
76
|
+
const parsed = JSON5.parse(raw);
|
|
77
|
+
return PaiConfigSchema.parse(parsed);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (err.code === "ENOENT") return PaiConfigSchema.parse({});
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Load profiles.json5 */
|
|
84
|
+
async function loadProfiles() {
|
|
85
|
+
const profilesPath = getProfilesPath();
|
|
86
|
+
try {
|
|
87
|
+
const raw = await fs.readFile(profilesPath, "utf-8");
|
|
88
|
+
return JSON5.parse(raw);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err.code === "ENOENT") return { profiles: {} };
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/utils/slug.ts
|
|
97
|
+
/** Generate a URL-safe slug from a title string */
|
|
98
|
+
function generateSlug(title) {
|
|
99
|
+
return title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").slice(0, 50);
|
|
100
|
+
}
|
|
101
|
+
/** Generate a timestamped filename: YYYY-MM-DDTHH-MM-{slug}.md */
|
|
102
|
+
function generateRawFilename(title) {
|
|
103
|
+
return `${(/* @__PURE__ */ new Date()).toISOString().replace(/:\d{2}\.\d{3}Z$/, "").replace(/:/g, "-")}-${generateSlug(title) || "untitled"}.md`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region src/utils/frontmatter.ts
|
|
108
|
+
/** Parse a markdown file with frontmatter */
|
|
109
|
+
function parseFrontmatter(content) {
|
|
110
|
+
const result = matter(content);
|
|
111
|
+
return {
|
|
112
|
+
data: result.data,
|
|
113
|
+
content: result.content
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Stringify data + content into a frontmatter markdown string */
|
|
117
|
+
function stringifyFrontmatter(data, content) {
|
|
118
|
+
return matter.stringify(content, data);
|
|
119
|
+
}
|
|
120
|
+
/** Create a raw file with proper frontmatter */
|
|
121
|
+
function createRawFile(frontmatter, title, body) {
|
|
122
|
+
return stringifyFrontmatter(frontmatter, `\n# ${title}\n\n${body}\n`);
|
|
123
|
+
}
|
|
124
|
+
/** Update frontmatter fields in a raw file */
|
|
125
|
+
function updateRawFrontmatter(fileContent, updates) {
|
|
126
|
+
const { data, content } = parseFrontmatter(fileContent);
|
|
127
|
+
return stringifyFrontmatter({
|
|
128
|
+
...data,
|
|
129
|
+
...updates
|
|
130
|
+
}, content);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/raw/add.ts
|
|
135
|
+
/** Add plain text content to raw/local/ */
|
|
136
|
+
async function addText(content, source = "local") {
|
|
137
|
+
const title = extractTitle(content);
|
|
138
|
+
const filename = generateRawFilename(title);
|
|
139
|
+
const dir = path.join(getRawDir(), "local");
|
|
140
|
+
await fs.mkdir(dir, { recursive: true });
|
|
141
|
+
const fileContent = createRawFile({
|
|
142
|
+
source,
|
|
143
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
144
|
+
status: "pending"
|
|
145
|
+
}, title, content);
|
|
146
|
+
const filePath = path.join(dir, filename);
|
|
147
|
+
await fs.writeFile(filePath, fileContent, "utf-8");
|
|
148
|
+
return filePath;
|
|
149
|
+
}
|
|
150
|
+
/** Add a local file's content to raw/local/ */
|
|
151
|
+
async function addFile(filePath, source = "local") {
|
|
152
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
153
|
+
const basename = path.basename(filePath, path.extname(filePath));
|
|
154
|
+
const filename = generateRawFilename(basename);
|
|
155
|
+
const dir = path.join(getRawDir(), "local");
|
|
156
|
+
await fs.mkdir(dir, { recursive: true });
|
|
157
|
+
const fileContent = createRawFile({
|
|
158
|
+
source,
|
|
159
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
160
|
+
status: "pending"
|
|
161
|
+
}, basename, content);
|
|
162
|
+
const outPath = path.join(dir, filename);
|
|
163
|
+
await fs.writeFile(outPath, fileContent, "utf-8");
|
|
164
|
+
return outPath;
|
|
165
|
+
}
|
|
166
|
+
/** Add URL-scraped content to raw/web/ */
|
|
167
|
+
async function addUrl(url, title, markdown) {
|
|
168
|
+
const filename = generateRawFilename(title);
|
|
169
|
+
const dir = path.join(getRawDir(), "web");
|
|
170
|
+
await fs.mkdir(dir, { recursive: true });
|
|
171
|
+
const fileContent = createRawFile({
|
|
172
|
+
source: "web",
|
|
173
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
174
|
+
url,
|
|
175
|
+
status: "pending"
|
|
176
|
+
}, title, markdown);
|
|
177
|
+
const filePath = path.join(dir, filename);
|
|
178
|
+
await fs.writeFile(filePath, fileContent, "utf-8");
|
|
179
|
+
return filePath;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Add or update a connector-scanned entry to raw/connector/{name}/.
|
|
183
|
+
* Uses scan_id in frontmatter for dedup:
|
|
184
|
+
* - If no existing file with same scan_id → create new
|
|
185
|
+
* - If existing file with same content body → skip ("skipped")
|
|
186
|
+
* - If existing file with different content → overwrite ("updated")
|
|
187
|
+
* Returns "created" | "updated" | "skipped".
|
|
188
|
+
*/
|
|
189
|
+
async function addConnectorEntry(connectorName, entry) {
|
|
190
|
+
const dir = path.join(getRawDir(), "connector", connectorName);
|
|
191
|
+
await fs.mkdir(dir, { recursive: true });
|
|
192
|
+
const scanId = `${connectorName}/${entry.id}`;
|
|
193
|
+
const existingPath = await findByScanId(dir, scanId);
|
|
194
|
+
const fm = {
|
|
195
|
+
source: `connector/${connectorName}`,
|
|
196
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
197
|
+
status: "pending",
|
|
198
|
+
scan_id: scanId
|
|
199
|
+
};
|
|
200
|
+
if (existingPath) {
|
|
201
|
+
const { content: oldBody } = parseFrontmatter(await fs.readFile(existingPath, "utf-8"));
|
|
202
|
+
const newBody = `\n# ${entry.title}\n\n${entry.content}\n`;
|
|
203
|
+
if (oldBody.trim() === newBody.trim()) return "skipped";
|
|
204
|
+
const fileContent = createRawFile(fm, entry.title, entry.content);
|
|
205
|
+
await fs.writeFile(existingPath, fileContent, "utf-8");
|
|
206
|
+
return "updated";
|
|
207
|
+
}
|
|
208
|
+
const filename = generateRawFilename(entry.id);
|
|
209
|
+
const fileContent = createRawFile(fm, entry.title, entry.content);
|
|
210
|
+
const filePath = path.join(dir, filename);
|
|
211
|
+
await fs.writeFile(filePath, fileContent, "utf-8");
|
|
212
|
+
return "created";
|
|
213
|
+
}
|
|
214
|
+
/** Find a raw file in a directory whose frontmatter scan_id matches */
|
|
215
|
+
async function findByScanId(dir, scanId) {
|
|
216
|
+
try {
|
|
217
|
+
const entries = await fs.readdir(dir);
|
|
218
|
+
for (const name of entries) {
|
|
219
|
+
if (!name.endsWith(".md")) continue;
|
|
220
|
+
const filePath = path.join(dir, name);
|
|
221
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
222
|
+
if (content.includes(`scan_id: ${scanId}`) || content.includes(`scan_id: '${scanId}'`)) return filePath;
|
|
223
|
+
}
|
|
224
|
+
} catch {}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
/** List all pending raw files (status: pending) */
|
|
228
|
+
async function listPending() {
|
|
229
|
+
const rawDir = getRawDir();
|
|
230
|
+
const pending = [];
|
|
231
|
+
for (const sub of [
|
|
232
|
+
"local",
|
|
233
|
+
"web",
|
|
234
|
+
"connector"
|
|
235
|
+
]) {
|
|
236
|
+
const dir = path.join(rawDir, sub);
|
|
237
|
+
try {
|
|
238
|
+
const entries = await walkDir(dir);
|
|
239
|
+
for (const entry of entries) if (entry.endsWith(".md")) {
|
|
240
|
+
if ((await fs.readFile(entry, "utf-8")).includes("status: pending")) pending.push(entry);
|
|
241
|
+
}
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
return pending;
|
|
245
|
+
}
|
|
246
|
+
/** Recursively walk a directory and return file paths */
|
|
247
|
+
async function walkDir(dir) {
|
|
248
|
+
const results = [];
|
|
249
|
+
try {
|
|
250
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
251
|
+
for (const entry of entries) {
|
|
252
|
+
const fullPath = path.join(dir, entry.name);
|
|
253
|
+
if (entry.isDirectory()) results.push(...await walkDir(fullPath));
|
|
254
|
+
else results.push(fullPath);
|
|
255
|
+
}
|
|
256
|
+
} catch {}
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
/** Extract a title from content (first line or first N words) */
|
|
260
|
+
function extractTitle(content) {
|
|
261
|
+
const cleaned = (content.split("\n")[0]?.trim() ?? "").replace(/^#+\s*/, "");
|
|
262
|
+
if (cleaned.length > 0 && cleaned.length <= 100) return cleaned;
|
|
263
|
+
return content.split(/\s+/).slice(0, 8).join(" ").slice(0, 100) || "untitled";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/utils/console.ts
|
|
268
|
+
function info(message) {
|
|
269
|
+
console.log(chalk.blue("ℹ"), message);
|
|
270
|
+
}
|
|
271
|
+
function warn(message) {
|
|
272
|
+
console.warn(chalk.yellow("⚠"), message);
|
|
273
|
+
}
|
|
274
|
+
function spinner(text) {
|
|
275
|
+
return ora({
|
|
276
|
+
text,
|
|
277
|
+
color: "cyan"
|
|
278
|
+
}).start();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/scraper/index.ts
|
|
283
|
+
/**
|
|
284
|
+
* Scrape a URL and return title + markdown content.
|
|
285
|
+
* Uses fetch + defuddle for content extraction.
|
|
286
|
+
* Falls back to basic HTML extraction if defuddle is unavailable.
|
|
287
|
+
*/
|
|
288
|
+
async function scrapeUrl(url, timeout = 3e4) {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
291
|
+
let html;
|
|
292
|
+
try {
|
|
293
|
+
const response = await fetch(url, {
|
|
294
|
+
signal: controller.signal,
|
|
295
|
+
headers: {
|
|
296
|
+
"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",
|
|
297
|
+
Accept: "text/html,application/xhtml+xml"
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
301
|
+
html = await response.text();
|
|
302
|
+
} finally {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const defuddleMod = await import("defuddle/node");
|
|
307
|
+
const Defuddle = defuddleMod.Defuddle ?? defuddleMod.default;
|
|
308
|
+
if (Defuddle) {
|
|
309
|
+
const result = new Defuddle(html, { url }).parse();
|
|
310
|
+
return {
|
|
311
|
+
url,
|
|
312
|
+
title: result.title || extractTitleFromHtml(html),
|
|
313
|
+
markdown: result.content ? htmlToSimpleMarkdown(result.content) : extractTextFromHtml(html)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
warn("defuddle not available, using basic HTML extraction");
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
url,
|
|
321
|
+
title: extractTitleFromHtml(html),
|
|
322
|
+
markdown: extractTextFromHtml(html)
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
/** Extract <title> from HTML */
|
|
326
|
+
function extractTitleFromHtml(html) {
|
|
327
|
+
return html.match(/<title[^>]*>([^<]+)<\/title>/i)?.[1]?.trim() ?? "Untitled";
|
|
328
|
+
}
|
|
329
|
+
/** Basic HTML to text extraction (fallback) */
|
|
330
|
+
function extractTextFromHtml(html) {
|
|
331
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/ /g, " ").replace(/\s+/g, " ").trim().slice(0, 1e4);
|
|
332
|
+
}
|
|
333
|
+
/** Convert simple HTML to markdown */
|
|
334
|
+
function htmlToSimpleMarkdown(html) {
|
|
335
|
+
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/'/g, "'").replace(/ /g, " ").replace(/\n{3,}/g, "\n\n").trim();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/utils/process.ts
|
|
340
|
+
/** Execute QMD CLI command and return stdout */
|
|
341
|
+
async function execQmd(args) {
|
|
342
|
+
return new Promise((resolve, reject) => {
|
|
343
|
+
execFile("qmd", args, {
|
|
344
|
+
encoding: "utf-8",
|
|
345
|
+
timeout: 6e4
|
|
346
|
+
}, (err, stdout, stderr) => {
|
|
347
|
+
if (err) {
|
|
348
|
+
const msg = stderr?.trim() || err.message;
|
|
349
|
+
reject(/* @__PURE__ */ new Error(`qmd ${args[0]} failed: ${msg}`));
|
|
350
|
+
} else resolve(stdout);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** Check if QMD is available on PATH */
|
|
355
|
+
async function isQmdAvailable() {
|
|
356
|
+
try {
|
|
357
|
+
await execQmd(["status"]);
|
|
358
|
+
return true;
|
|
359
|
+
} catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/search/index.ts
|
|
366
|
+
/**
|
|
367
|
+
* Hybrid search via QMD.
|
|
368
|
+
* - "query" (default): BM25 + vector + expansion + reranking (~5s, best quality)
|
|
369
|
+
* - "search": BM25 keyword + rg grep fallback (~50ms, fast)
|
|
370
|
+
* - "vsearch": Vector similarity only (~2s, good for semantic)
|
|
371
|
+
*
|
|
372
|
+
* For "search" (fast) mode, QMD BM25 doesn't support CJK tokenization,
|
|
373
|
+
* so we supplement with ripgrep to cover Chinese/Japanese/Korean.
|
|
374
|
+
*/
|
|
375
|
+
async function search(query, collection = "vault", n = 5, mode = "query") {
|
|
376
|
+
if (!await isQmdAvailable()) throw new Error("QMD is not installed. Install with: npm install -g https://github.com/tobi/qmd");
|
|
377
|
+
let results = parseSearchResults(await execQmd([
|
|
378
|
+
mode,
|
|
379
|
+
query,
|
|
380
|
+
"--collection",
|
|
381
|
+
collection,
|
|
382
|
+
"--json",
|
|
383
|
+
"-n",
|
|
384
|
+
String(n)
|
|
385
|
+
]));
|
|
386
|
+
if (mode === "search" && results.length === 0 && hasCjk(query)) results = await grepFallback(query, collection, n);
|
|
387
|
+
return results;
|
|
388
|
+
}
|
|
389
|
+
/** Parse QMD JSON search output into SearchResult[] */
|
|
390
|
+
function parseSearchResults(stdout) {
|
|
391
|
+
try {
|
|
392
|
+
const parsed = JSON.parse(stdout);
|
|
393
|
+
if (!Array.isArray(parsed)) return [];
|
|
394
|
+
return parsed.map((item) => ({
|
|
395
|
+
file: item.file ?? "",
|
|
396
|
+
title: item.title ?? void 0,
|
|
397
|
+
snippet: item.snippet ?? item.content?.slice(0, 200) ?? "",
|
|
398
|
+
score: item.score
|
|
399
|
+
}));
|
|
400
|
+
} catch {
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** Check if a string contains CJK characters */
|
|
405
|
+
function hasCjk(text) {
|
|
406
|
+
return /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(text);
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* grep fallback for CJK text that BM25 can't tokenize.
|
|
410
|
+
* Runs: grep -rl "query" <dir> --include="*.md"
|
|
411
|
+
* Then reads matched files and extracts context.
|
|
412
|
+
*/
|
|
413
|
+
async function grepFallback(query, collection, n) {
|
|
414
|
+
const baseDir = collection === "raw" ? getRawDir() : getVaultDir();
|
|
415
|
+
const stdout = await new Promise((resolve) => {
|
|
416
|
+
execFile("grep", [
|
|
417
|
+
"-rl",
|
|
418
|
+
"--include=*.md",
|
|
419
|
+
query,
|
|
420
|
+
baseDir
|
|
421
|
+
], {
|
|
422
|
+
encoding: "utf-8",
|
|
423
|
+
timeout: 5e3
|
|
424
|
+
}, (_err, out) => {
|
|
425
|
+
resolve(out ?? "");
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
if (!stdout.trim()) return [];
|
|
429
|
+
const files = stdout.trim().split("\n").slice(0, n);
|
|
430
|
+
const results = [];
|
|
431
|
+
for (const filePath of files) {
|
|
432
|
+
if (!filePath) continue;
|
|
433
|
+
const snippet = (await new Promise((resolve) => {
|
|
434
|
+
execFile("grep", [
|
|
435
|
+
"-m1",
|
|
436
|
+
"-C1",
|
|
437
|
+
query,
|
|
438
|
+
filePath
|
|
439
|
+
], {
|
|
440
|
+
encoding: "utf-8",
|
|
441
|
+
timeout: 2e3
|
|
442
|
+
}, (_err, out) => resolve(out ?? ""));
|
|
443
|
+
})).replace(/\n/g, " ").trim().slice(0, 160);
|
|
444
|
+
const relative = path.relative(baseDir, filePath);
|
|
445
|
+
const titleLine = await new Promise((resolve) => {
|
|
446
|
+
execFile("grep", [
|
|
447
|
+
"-m1",
|
|
448
|
+
"^# ",
|
|
449
|
+
filePath
|
|
450
|
+
], {
|
|
451
|
+
encoding: "utf-8",
|
|
452
|
+
timeout: 1e3
|
|
453
|
+
}, (_err, out) => resolve(out?.replace(/^#\s+/, "").trim() ?? ""));
|
|
454
|
+
});
|
|
455
|
+
results.push({
|
|
456
|
+
file: `qmd://${collection}/${relative}`,
|
|
457
|
+
title: titleLine || path.basename(filePath, ".md"),
|
|
458
|
+
snippet,
|
|
459
|
+
score: 1
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
return results;
|
|
463
|
+
}
|
|
464
|
+
/** Update QMD index: runs `qmd update` + `qmd embed` */
|
|
465
|
+
async function updateIndex() {
|
|
466
|
+
await execQmd(["update"]);
|
|
467
|
+
await execQmd(["embed"]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/llm/client.ts
|
|
472
|
+
let cachedConfig = null;
|
|
473
|
+
let cachedPreferences = null;
|
|
474
|
+
async function getConfig() {
|
|
475
|
+
if (!cachedConfig) cachedConfig = await loadConfig();
|
|
476
|
+
return cachedConfig;
|
|
477
|
+
}
|
|
478
|
+
/** Load user preferences.md (cached per process) */
|
|
479
|
+
async function getPreferences() {
|
|
480
|
+
if (cachedPreferences !== null) return cachedPreferences;
|
|
481
|
+
try {
|
|
482
|
+
cachedPreferences = await fs.readFile(getPreferencesPath(), "utf-8");
|
|
483
|
+
} catch {
|
|
484
|
+
cachedPreferences = "";
|
|
485
|
+
}
|
|
486
|
+
return cachedPreferences;
|
|
487
|
+
}
|
|
488
|
+
/** Create an OpenAI-compatible client from config */
|
|
489
|
+
async function createLlmClient() {
|
|
490
|
+
const config = await getConfig();
|
|
491
|
+
const apiKey = process.env[config.llm.apiKeyEnv];
|
|
492
|
+
if (!apiKey) throw new Error(`Missing API key: set ${config.llm.apiKeyEnv} environment variable`);
|
|
493
|
+
return new OpenAI({
|
|
494
|
+
apiKey,
|
|
495
|
+
baseURL: config.llm.baseUrl || void 0
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Single LLM call with system + user messages.
|
|
500
|
+
* Automatically prepends user preferences to the system prompt.
|
|
501
|
+
* Retries once on failure.
|
|
502
|
+
*/
|
|
503
|
+
async function llmCall(prompt, system, model) {
|
|
504
|
+
const config = await getConfig();
|
|
505
|
+
const client = await createLlmClient();
|
|
506
|
+
const modelName = model ?? config.llm.cheapModel;
|
|
507
|
+
const prefs = await getPreferences();
|
|
508
|
+
const fullSystem = prefs ? `${system}\n\n---USER PREFERENCES (always respect these)---\n${prefs}` : system;
|
|
509
|
+
for (let attempt = 0; attempt < 2; attempt++) try {
|
|
510
|
+
return (await client.chat.completions.create({
|
|
511
|
+
model: modelName,
|
|
512
|
+
messages: [{
|
|
513
|
+
role: "system",
|
|
514
|
+
content: fullSystem
|
|
515
|
+
}, {
|
|
516
|
+
role: "user",
|
|
517
|
+
content: prompt
|
|
518
|
+
}],
|
|
519
|
+
temperature: .3
|
|
520
|
+
})).choices[0]?.message?.content ?? "";
|
|
521
|
+
} catch (err) {
|
|
522
|
+
if (attempt === 0) {
|
|
523
|
+
await new Promise((r) => setTimeout(r, 1e3));
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
throw err;
|
|
527
|
+
}
|
|
528
|
+
return "";
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/prompts/triage.ts
|
|
533
|
+
/** Build system prompt for the triage step */
|
|
534
|
+
function triageSystemPrompt() {
|
|
535
|
+
return `You are a personal knowledge management assistant.
|
|
536
|
+
Your job is to evaluate raw input and extract ALL distinct pieces of personal context worth preserving.
|
|
537
|
+
|
|
538
|
+
CRITICAL: One raw input often contains MULTIPLE types of data. You MUST split them into separate entries routed to different vault files.
|
|
539
|
+
|
|
540
|
+
Two kinds of valuable content:
|
|
541
|
+
|
|
542
|
+
1) EXPERIENTIAL: concrete lessons, preferences, tips, or experiences.
|
|
543
|
+
2) SYSTEM CONTEXT: data from system/connector scans — identity, environment, habits, preferences.
|
|
544
|
+
|
|
545
|
+
Routing targets:
|
|
546
|
+
- vault/context/identity.md — name, email, role, languages, locale, timezone
|
|
547
|
+
- vault/context/active-projects.md — repos, cloud infra, SSH hosts
|
|
548
|
+
- vault/preferences/tools.md — tech stack, IDEs, runtimes, package managers
|
|
549
|
+
- vault/preferences/workflow.md — shell habits, git config, aliases, dev workflow
|
|
550
|
+
- vault/life/interests.md — interests, domains of focus, bookmarks, browsing patterns
|
|
551
|
+
- vault/life/lifestyle.md — calendar, apps, music, media, daily routines
|
|
552
|
+
- vault/coding/*.md — coding lessons (use existing or suggest new)
|
|
553
|
+
- vault/work/*.md — work-related lessons and context
|
|
554
|
+
|
|
555
|
+
Do NOT mark as valuable: generic news, ads, or content with no personal signal.
|
|
556
|
+
|
|
557
|
+
Respond ONLY with valid JSON (no markdown fences):
|
|
558
|
+
{
|
|
559
|
+
"valuable": true/false,
|
|
560
|
+
"entries": [
|
|
561
|
+
{ "targetFile": "vault/context/identity.md", "extract": "name, email, role, languages" },
|
|
562
|
+
{ "targetFile": "vault/preferences/tools.md", "extract": "IDE, runtimes, package managers" }
|
|
563
|
+
],
|
|
564
|
+
"reason": "brief explanation"
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
Rules:
|
|
568
|
+
- entries array can have 1-6 items — split aggressively by topic
|
|
569
|
+
- Each entry.extract is a SHORT directive telling the distill step WHAT to pull for that target
|
|
570
|
+
- If not valuable, entries should be empty []
|
|
571
|
+
- Use existing vault files from the list when available`;
|
|
572
|
+
}
|
|
573
|
+
/** Build user prompt for triage */
|
|
574
|
+
function triageUserPrompt(rawContent, vaultFiles) {
|
|
575
|
+
return `Evaluate this raw input and decide if it's worth distilling:
|
|
576
|
+
|
|
577
|
+
---RAW CONTENT---
|
|
578
|
+
${rawContent}
|
|
579
|
+
---END RAW CONTENT---
|
|
580
|
+
${vaultFiles.length > 0 ? `\nExisting vault files:\n${vaultFiles.map((f) => `- ${f}`).join("\n")}` : "\nNo existing vault files yet."}
|
|
581
|
+
|
|
582
|
+
Route to context/, preferences/, life/, coding/, or work/ as appropriate. Respond with JSON only.`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
//#endregion
|
|
586
|
+
//#region src/distill/triage.ts
|
|
587
|
+
/** Run triage on a raw file to determine if it's worth distilling */
|
|
588
|
+
async function triageRawFile(rawContent, vaultFiles) {
|
|
589
|
+
const system = triageSystemPrompt();
|
|
590
|
+
const response = await llmCall(triageUserPrompt(rawContent, vaultFiles), system);
|
|
591
|
+
try {
|
|
592
|
+
const cleaned = response.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
|
|
593
|
+
const raw = JSON.parse(cleaned);
|
|
594
|
+
let entries = [];
|
|
595
|
+
if (Array.isArray(raw.entries)) entries = raw.entries.filter((e) => typeof e.targetFile === "string" && typeof e.extract === "string").map((e) => ({
|
|
596
|
+
targetFile: e.targetFile,
|
|
597
|
+
extract: e.extract
|
|
598
|
+
}));
|
|
599
|
+
if (entries.length === 0 && typeof raw.targetFile === "string" && raw.targetFile) entries = [{
|
|
600
|
+
targetFile: raw.targetFile,
|
|
601
|
+
extract: "all relevant content"
|
|
602
|
+
}];
|
|
603
|
+
return {
|
|
604
|
+
valuable: Boolean(raw.valuable),
|
|
605
|
+
entries,
|
|
606
|
+
reason: raw.reason ?? ""
|
|
607
|
+
};
|
|
608
|
+
} catch {
|
|
609
|
+
return {
|
|
610
|
+
valuable: false,
|
|
611
|
+
entries: [],
|
|
612
|
+
reason: `Failed to parse triage response: ${response.slice(0, 100)}`
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/prompts/distill.ts
|
|
619
|
+
/** Build system prompt for the distill & merge step */
|
|
620
|
+
function distillSystemPrompt() {
|
|
621
|
+
return `You are a personal knowledge distillation assistant.
|
|
622
|
+
Your job is to extract and merge valuable personal context from raw input into a vault document.
|
|
623
|
+
|
|
624
|
+
Two modes:
|
|
625
|
+
|
|
626
|
+
A) EXPERIENTIAL content (lessons, tips, experiences):
|
|
627
|
+
- Extract ONLY conclusions and actionable knowledge — never copy raw text verbatim
|
|
628
|
+
- If a similar experience already exists, increment its verification count
|
|
629
|
+
- Each bullet: (source, date | ref: raw/path)
|
|
630
|
+
|
|
631
|
+
B) SYSTEM CONTEXT content (identity, environment, habits, bookmarks, etc.):
|
|
632
|
+
- Do NOT dump raw lists verbatim (e.g. every package path or every bookmark folder)
|
|
633
|
+
- Synthesize into a compact, HIGH-DENSITY profile:
|
|
634
|
+
- Identity: name, role, languages, locale, timezone — one line each
|
|
635
|
+
- Tools: summarize tech stack concisely (e.g. "Full-stack: Node/Python/Go/Rust, IDEs: Cursor+Android Studio")
|
|
636
|
+
- Workflow: summarize habits (e.g. "Heavy git user (4762 commands), frequent claude CLI, command-line focused")
|
|
637
|
+
- Life: summarize calendar themes, main apps, browsing focus — with concrete data
|
|
638
|
+
- Interests: summarize bookmark categories AND top domains as interest tags (e.g. "AI/LLM, GIS, iOS, Python, Web3")
|
|
639
|
+
- Projects: summarize active areas with names (e.g. "PINAI (PIN-APP-IOS, PIN-AGENT-WEB), consulting, agent-market")
|
|
640
|
+
|
|
641
|
+
CRITICAL RULES:
|
|
642
|
+
- NEVER write "未提供", "未指定", "Not specified", "Unknown" — if data is not in the raw input, simply OMIT that field
|
|
643
|
+
- Only include sections and fields that have ACTUAL data from the raw input
|
|
644
|
+
- Do NOT create empty placeholder sections. If an H2 section would be empty, skip it entirely
|
|
645
|
+
- Each vault file handles ONE topic — only include relevant data from the raw input
|
|
646
|
+
- Keep information density HIGH: pack maximum facts per line
|
|
647
|
+
- Maintain H2 (##) section structure
|
|
648
|
+
- Keep each H2 section between 200-800 tokens
|
|
649
|
+
- Preserve all existing vault content — only add or update, never remove
|
|
650
|
+
- Output the COMPLETE updated vault file content (not just the changes)
|
|
651
|
+
- End each new bullet with source ref: (source, date | ref: raw/path/to/file.md)`;
|
|
652
|
+
}
|
|
653
|
+
/** Build user prompt for distill & merge */
|
|
654
|
+
function distillUserPrompt(rawContent, rawFilePath, existingVaultContent, extractDirective) {
|
|
655
|
+
return `Distill the following raw input and merge into the vault document:
|
|
656
|
+
|
|
657
|
+
---RAW CONTENT (from: ${rawFilePath})---
|
|
658
|
+
${rawContent}
|
|
659
|
+
---END RAW CONTENT---
|
|
660
|
+
|
|
661
|
+
${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."}
|
|
662
|
+
${extractDirective ? `\nFOCUS: Only extract data related to: ${extractDirective}. Ignore unrelated content in the raw input.` : ""}
|
|
663
|
+
Output the COMPLETE updated vault file content.
|
|
664
|
+
Rules: synthesize into high-density profile. NEVER write "未提供"/"Not specified"/"Unknown" — omit fields with no data instead. Only include sections with actual data.`;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/distill/merge.ts
|
|
669
|
+
/**
|
|
670
|
+
* Distill raw content and merge into a vault file.
|
|
671
|
+
* Creates the vault file if it doesn't exist.
|
|
672
|
+
* @param extractDirective - Optional hint telling LLM what to extract from raw content for this target
|
|
673
|
+
*/
|
|
674
|
+
async function distillAndMerge(rawContent, rawFilePath, targetFile, extractDirective) {
|
|
675
|
+
const vaultDir = getVaultDir();
|
|
676
|
+
const relativePath = targetFile.replace(/^vault\//, "");
|
|
677
|
+
const fullPath = path.join(vaultDir, relativePath);
|
|
678
|
+
let existingContent = null;
|
|
679
|
+
try {
|
|
680
|
+
existingContent = await fs.readFile(fullPath, "utf-8");
|
|
681
|
+
} catch {}
|
|
682
|
+
const system = distillSystemPrompt();
|
|
683
|
+
const updatedContent = await llmCall(distillUserPrompt(rawContent, rawFilePath, existingContent, extractDirective), system);
|
|
684
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
685
|
+
await fs.writeFile(fullPath, updatedContent.trim() + "\n", "utf-8");
|
|
686
|
+
return fullPath;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
//#endregion
|
|
690
|
+
//#region src/distill/index.ts
|
|
691
|
+
/** List all existing vault files (relative paths like "coding/python.md") */
|
|
692
|
+
async function listVaultFiles() {
|
|
693
|
+
const vaultDir = getVaultDir();
|
|
694
|
+
const files = [];
|
|
695
|
+
async function walk(dir) {
|
|
696
|
+
try {
|
|
697
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
698
|
+
for (const entry of entries) {
|
|
699
|
+
const fullPath = path.join(dir, entry.name);
|
|
700
|
+
if (entry.isDirectory()) await walk(fullPath);
|
|
701
|
+
else if (entry.name.endsWith(".md")) files.push(path.relative(vaultDir, fullPath));
|
|
702
|
+
}
|
|
703
|
+
} catch {}
|
|
704
|
+
}
|
|
705
|
+
await walk(vaultDir);
|
|
706
|
+
return files;
|
|
707
|
+
}
|
|
708
|
+
/** Run the full distill pipeline on all pending raw files */
|
|
709
|
+
async function distillPipeline(options = {}) {
|
|
710
|
+
const result = {
|
|
711
|
+
processed: 0,
|
|
712
|
+
valuable: 0,
|
|
713
|
+
discarded: 0,
|
|
714
|
+
errors: []
|
|
715
|
+
};
|
|
716
|
+
let pendingFiles;
|
|
717
|
+
if (options.singleFile) pendingFiles = [options.singleFile];
|
|
718
|
+
else pendingFiles = await listPending();
|
|
719
|
+
if (pendingFiles.length === 0) {
|
|
720
|
+
info("No pending raw files to process.");
|
|
721
|
+
return result;
|
|
722
|
+
}
|
|
723
|
+
info(`Found ${pendingFiles.length} pending file(s) to process.`);
|
|
724
|
+
const vaultFilesList = (await listVaultFiles()).map((f) => `vault/${f}`);
|
|
725
|
+
for (const filePath of pendingFiles) {
|
|
726
|
+
const spin = spinner(`Processing ${path.basename(filePath)}...`);
|
|
727
|
+
try {
|
|
728
|
+
const rawFileContent = await fs.readFile(filePath, "utf-8");
|
|
729
|
+
const { content } = parseFrontmatter(rawFileContent);
|
|
730
|
+
const triage = await triageRawFile(content, vaultFilesList);
|
|
731
|
+
result.processed++;
|
|
732
|
+
if (!triage.valuable || triage.entries.length === 0) {
|
|
733
|
+
spin.succeed(`Discarded: ${path.basename(filePath)} — ${triage.reason}`);
|
|
734
|
+
result.discarded++;
|
|
735
|
+
if (!options.dryRun) {
|
|
736
|
+
const updated = updateRawFrontmatter(rawFileContent, { status: "discarded" });
|
|
737
|
+
await fs.writeFile(filePath, updated, "utf-8");
|
|
738
|
+
}
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const targets = triage.entries.map((e) => e.targetFile);
|
|
742
|
+
if (options.dryRun) {
|
|
743
|
+
spin.succeed(`[DRY RUN] Would distill ${path.basename(filePath)} → ${targets.join(", ")}`);
|
|
744
|
+
result.valuable++;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
const mergedPaths = [];
|
|
748
|
+
for (const entry of triage.entries) {
|
|
749
|
+
const targetFile = entry.targetFile || "vault/context/misc.md";
|
|
750
|
+
const vaultPath = await distillAndMerge(content, filePath, targetFile, entry.extract);
|
|
751
|
+
mergedPaths.push(vaultPath);
|
|
752
|
+
if (!vaultFilesList.includes(targetFile)) vaultFilesList.push(targetFile);
|
|
753
|
+
}
|
|
754
|
+
result.valuable++;
|
|
755
|
+
const updated = updateRawFrontmatter(rawFileContent, {
|
|
756
|
+
status: "processed",
|
|
757
|
+
distilled_to: mergedPaths.map((p) => path.relative(path.dirname(filePath).replace(/\/raw\/.*/, "/raw"), p)).join(", ")
|
|
758
|
+
});
|
|
759
|
+
await fs.writeFile(filePath, updated, "utf-8");
|
|
760
|
+
spin.succeed(`Distilled ${path.basename(filePath)} → ${targets.join(", ")} (${triage.entries.length} entries)`);
|
|
761
|
+
} catch (err) {
|
|
762
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
763
|
+
spin.fail(`Error processing ${path.basename(filePath)}: ${msg}`);
|
|
764
|
+
result.errors.push(`${filePath}: ${msg}`);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return result;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
//#endregion
|
|
771
|
+
//#region src/prompts/generate.ts
|
|
772
|
+
/** Build system prompt for SKILL.md generation */
|
|
773
|
+
function generateSystemPrompt() {
|
|
774
|
+
return `You are generating a concise SKILL.md personal context file for an AI agent.
|
|
775
|
+
This file helps the agent understand the user's background, preferences, and experiences.
|
|
776
|
+
|
|
777
|
+
Rules:
|
|
778
|
+
- Be extremely concise — every line must carry HIGH information density
|
|
779
|
+
- Pack maximum concrete facts per bullet (names, numbers, specific tools)
|
|
780
|
+
- Prioritize experiences with higher verification counts when present
|
|
781
|
+
- Use bullet points for each piece of context
|
|
782
|
+
- Follow the exact section structure provided
|
|
783
|
+
- Do NOT include generic advice — only user-specific context
|
|
784
|
+
- NEVER write filler like "未提供", "Not specified" or vague statements
|
|
785
|
+
- Write in the same language as the source content
|
|
786
|
+
|
|
787
|
+
When vault content includes identity/tools/workflow/interests data:
|
|
788
|
+
- "Who I Am": real name, email, languages with specifics, locale, timezone, tech identity
|
|
789
|
+
- "Preferences": specific tools by name, workflow patterns with data (e.g. "git: 4762 commands"), communication style
|
|
790
|
+
- "Hard-won Lessons": only include if genuine lessons exist; otherwise omit or make it 1-2 lines
|
|
791
|
+
- "Current Work": specific project names, focus areas, active repos — be concrete`;
|
|
792
|
+
}
|
|
793
|
+
/** Build user prompt for SKILL.md generation */
|
|
794
|
+
function generateUserPrompt(profileName, vaultContents, maxLines) {
|
|
795
|
+
return `Generate a SKILL.md profile called "${profileName}" from the following vault content.
|
|
796
|
+
Maximum ${maxLines} lines total.
|
|
797
|
+
|
|
798
|
+
Required sections:
|
|
799
|
+
# Personal Context — ${profileName}
|
|
800
|
+
|
|
801
|
+
## Who I Am
|
|
802
|
+
## Preferences
|
|
803
|
+
## Hard-won Lessons
|
|
804
|
+
## Current Work
|
|
805
|
+
|
|
806
|
+
---VAULT CONTENT---
|
|
807
|
+
${vaultContents}
|
|
808
|
+
---END VAULT CONTENT---
|
|
809
|
+
|
|
810
|
+
Generate the SKILL.md now. Stay within ${maxLines} lines.
|
|
811
|
+
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 "未提供".`;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
//#endregion
|
|
815
|
+
//#region src/generate/index.ts
|
|
816
|
+
/** Recursively list all markdown files in a directory */
|
|
817
|
+
async function walkMd(dir) {
|
|
818
|
+
const results = [];
|
|
819
|
+
try {
|
|
820
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
821
|
+
for (const entry of entries) {
|
|
822
|
+
const full = path.join(dir, entry.name);
|
|
823
|
+
if (entry.isDirectory()) results.push(...await walkMd(full));
|
|
824
|
+
else if (entry.name.endsWith(".md")) results.push(full);
|
|
825
|
+
}
|
|
826
|
+
} catch {}
|
|
827
|
+
return results;
|
|
828
|
+
}
|
|
829
|
+
/** Collect vault file contents matching scope patterns */
|
|
830
|
+
async function collectVaultContent(scope) {
|
|
831
|
+
const vaultDir = getVaultDir();
|
|
832
|
+
const allFiles = await walkMd(vaultDir);
|
|
833
|
+
const matched = [];
|
|
834
|
+
for (const file of allFiles) {
|
|
835
|
+
const relative = "vault/" + path.relative(vaultDir, file);
|
|
836
|
+
for (const pattern of scope) if (minimatch(relative, pattern)) {
|
|
837
|
+
matched.push(file);
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const contents = [];
|
|
842
|
+
for (const file of matched) try {
|
|
843
|
+
const content = await fs.readFile(file, "utf-8");
|
|
844
|
+
contents.push(`--- ${path.relative(vaultDir, file)} ---\n${content}`);
|
|
845
|
+
} catch {}
|
|
846
|
+
return contents.join("\n\n");
|
|
847
|
+
}
|
|
848
|
+
/** Generate a single SKILL.md profile */
|
|
849
|
+
async function generateProfile(profileName) {
|
|
850
|
+
const profileDef = (await loadProfiles()).profiles[profileName];
|
|
851
|
+
if (!profileDef) throw new Error(`Profile "${profileName}" not found in profiles.json5`);
|
|
852
|
+
const vaultContent = await collectVaultContent(profileDef.scope);
|
|
853
|
+
if (!vaultContent.trim()) throw new Error(`No vault content found for profile "${profileName}". Run 'pai distill' first.`);
|
|
854
|
+
const system = generateSystemPrompt();
|
|
855
|
+
const skillMd = await llmCall(generateUserPrompt(profileName, vaultContent, profileDef.maxLines), system);
|
|
856
|
+
const skillsDir = getSkillsDir();
|
|
857
|
+
await fs.mkdir(skillsDir, { recursive: true });
|
|
858
|
+
const outPath = path.join(skillsDir, `${profileName}.md`);
|
|
859
|
+
await fs.writeFile(outPath, skillMd.trim() + "\n", "utf-8");
|
|
860
|
+
return outPath;
|
|
861
|
+
}
|
|
862
|
+
/** Generate all profiles defined in profiles.json5 */
|
|
863
|
+
async function generateAll() {
|
|
864
|
+
const profiles = await loadProfiles();
|
|
865
|
+
const profileNames = Object.keys(profiles.profiles);
|
|
866
|
+
if (profileNames.length === 0) {
|
|
867
|
+
warn("No profiles defined in profiles.json5");
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
const results = [];
|
|
871
|
+
for (const name of profileNames) {
|
|
872
|
+
const spin = spinner(`Generating profile: ${name}...`);
|
|
873
|
+
try {
|
|
874
|
+
const outPath = await generateProfile(name);
|
|
875
|
+
spin.succeed(`Generated ${name} → ${outPath}`);
|
|
876
|
+
results.push(outPath);
|
|
877
|
+
} catch (err) {
|
|
878
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
879
|
+
spin.fail(`Failed to generate ${name}: ${msg}`);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return results;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/profile/compile.ts
|
|
887
|
+
const PROFILE_SECTIONS = [
|
|
888
|
+
{
|
|
889
|
+
heading: "Identity",
|
|
890
|
+
collectors: ["identity-profile", "github-profile"]
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
heading: "Environment & Tools",
|
|
894
|
+
collectors: ["dev-environment", "dev-preferences"]
|
|
895
|
+
},
|
|
896
|
+
{
|
|
897
|
+
heading: "Work Style & Habits",
|
|
898
|
+
collectors: ["shell-habits", "coding-rules"]
|
|
899
|
+
},
|
|
900
|
+
{
|
|
901
|
+
heading: "Active Projects & Recent Focus",
|
|
902
|
+
collectors: ["active-projects", "recent-focus"]
|
|
903
|
+
},
|
|
904
|
+
{
|
|
905
|
+
heading: "Digital Footprint",
|
|
906
|
+
collectors: [
|
|
907
|
+
"browser-bookmarks",
|
|
908
|
+
"browser-domains",
|
|
909
|
+
"productivity-setup"
|
|
910
|
+
]
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
heading: "Registry & Cloud Accounts",
|
|
914
|
+
collectors: ["social-profiles"]
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
heading: "Context",
|
|
918
|
+
collectors: ["calendar-context", "file-organization"]
|
|
919
|
+
}
|
|
920
|
+
];
|
|
921
|
+
/**
|
|
922
|
+
* Compile CollectorResult[] into a structured profile markdown string.
|
|
923
|
+
* No LLM involved — pure formatting.
|
|
924
|
+
*/
|
|
925
|
+
function compileProfile(results) {
|
|
926
|
+
const index = /* @__PURE__ */ new Map();
|
|
927
|
+
for (const r of results) index.set(r.id, r);
|
|
928
|
+
const sections = ["# Personal Profile", ""];
|
|
929
|
+
for (const section of PROFILE_SECTIONS) {
|
|
930
|
+
const parts = [];
|
|
931
|
+
for (const cid of section.collectors) {
|
|
932
|
+
const result = index.get(cid);
|
|
933
|
+
if (result?.content?.trim()) parts.push(result.content.trim());
|
|
934
|
+
}
|
|
935
|
+
if (parts.length === 0) continue;
|
|
936
|
+
sections.push(`## ${section.heading}`, "");
|
|
937
|
+
sections.push(parts.join("\n\n"));
|
|
938
|
+
sections.push("");
|
|
939
|
+
}
|
|
940
|
+
const coveredIds = new Set(PROFILE_SECTIONS.flatMap((s) => s.collectors));
|
|
941
|
+
const extras = [];
|
|
942
|
+
for (const r of results) if (!coveredIds.has(r.id) && r.content?.trim()) extras.push(`## ${r.title}`, "", r.content.trim(), "");
|
|
943
|
+
if (extras.length > 0) sections.push(...extras);
|
|
944
|
+
sections.push("---");
|
|
945
|
+
sections.push(`Last updated: ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
946
|
+
sections.push("");
|
|
947
|
+
return sections.join("\n");
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
//#endregion
|
|
951
|
+
//#region src/connectors/sanitize.ts
|
|
952
|
+
/**
|
|
953
|
+
* Security sanitization utilities for connector-scanned content.
|
|
954
|
+
* Strips secrets, credentials, and sensitive paths before writing to raw layer.
|
|
955
|
+
*/
|
|
956
|
+
/** Pattern matching shell export lines containing secrets */
|
|
957
|
+
const SECRET_EXPORT_RE = /^(\s*export\s+)\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|PASSWD)\w*\s*=.*/i;
|
|
958
|
+
/** Pattern matching lines that look like inline secret assignments (no export) */
|
|
959
|
+
const SECRET_ASSIGN_RE = /^\s*\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|PASSWD)\w*\s*=\s*["']?\S+/i;
|
|
960
|
+
/**
|
|
961
|
+
* Strip secret export/assignment lines from shell config content.
|
|
962
|
+
* Preserves comments and non-secret lines intact.
|
|
963
|
+
*/
|
|
964
|
+
function stripSecretExports(content) {
|
|
965
|
+
return content.split("\n").map((line) => {
|
|
966
|
+
if (SECRET_EXPORT_RE.test(line) || SECRET_ASSIGN_RE.test(line)) return "# [REDACTED by pai — secret removed]";
|
|
967
|
+
return line;
|
|
968
|
+
}).join("\n");
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Strip [credential] sections from git config content.
|
|
972
|
+
* Removes the section header and all lines until the next section.
|
|
973
|
+
*/
|
|
974
|
+
function stripGitCredentials(content) {
|
|
975
|
+
const lines = content.split("\n");
|
|
976
|
+
const result = [];
|
|
977
|
+
let inCredentialSection = false;
|
|
978
|
+
for (const line of lines) {
|
|
979
|
+
if (/^\s*\[credential/i.test(line)) {
|
|
980
|
+
inCredentialSection = true;
|
|
981
|
+
result.push("# [REDACTED by pai — credential section removed]");
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (inCredentialSection && /^\s*\[/.test(line)) inCredentialSection = false;
|
|
985
|
+
if (!inCredentialSection) result.push(line);
|
|
986
|
+
}
|
|
987
|
+
return result.join("\n");
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Strip IdentityFile lines from SSH config.
|
|
991
|
+
* Keeps Host, HostName, User, Port — removes key paths.
|
|
992
|
+
*/
|
|
993
|
+
function stripSshIdentityFiles(content) {
|
|
994
|
+
return content.split("\n").map((line) => {
|
|
995
|
+
if (/^\s*IdentityFile\s/i.test(line)) return " # [REDACTED by pai — IdentityFile removed]";
|
|
996
|
+
return line;
|
|
997
|
+
}).join("\n");
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
//#endregion
|
|
1001
|
+
//#region src/connectors/mac/collectors.ts
|
|
1002
|
+
/**
|
|
1003
|
+
* Mac system context collectors.
|
|
1004
|
+
* Each collector gathers a specific category of personalized context
|
|
1005
|
+
* from the local macOS environment and returns a CollectorResult.
|
|
1006
|
+
*/
|
|
1007
|
+
/** Run a shell command and return stdout (empty string on failure) */
|
|
1008
|
+
async function exec(cmd, args) {
|
|
1009
|
+
return new Promise((resolve) => {
|
|
1010
|
+
execFile(cmd, args, {
|
|
1011
|
+
encoding: "utf-8",
|
|
1012
|
+
timeout: 15e3
|
|
1013
|
+
}, (err, stdout) => {
|
|
1014
|
+
resolve(err ? "" : stdout.trim());
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
}
|
|
1018
|
+
/** Read a file, return empty string on failure */
|
|
1019
|
+
async function readSafe(filePath) {
|
|
1020
|
+
try {
|
|
1021
|
+
return await fs.readFile(filePath, "utf-8");
|
|
1022
|
+
} catch {
|
|
1023
|
+
return "";
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/** Check if path exists */
|
|
1027
|
+
async function exists(p) {
|
|
1028
|
+
try {
|
|
1029
|
+
await fs.access(p);
|
|
1030
|
+
return true;
|
|
1031
|
+
} catch {
|
|
1032
|
+
return false;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
/** Run osascript and return output */
|
|
1036
|
+
async function osascript(script) {
|
|
1037
|
+
return exec("osascript", ["-e", script]);
|
|
1038
|
+
}
|
|
1039
|
+
/** Run defaults read and return output */
|
|
1040
|
+
async function defaultsRead(...args) {
|
|
1041
|
+
return exec("defaults", ["read", ...args]);
|
|
1042
|
+
}
|
|
1043
|
+
const HOME = os.homedir();
|
|
1044
|
+
async function collectIdentityProfile() {
|
|
1045
|
+
const lines = ["## User Identity"];
|
|
1046
|
+
const username = await exec("id", ["-un"]);
|
|
1047
|
+
if (username) lines.push(`- macOS Username: ${username}`);
|
|
1048
|
+
const nameMatch = (await exec("dscl", [
|
|
1049
|
+
".",
|
|
1050
|
+
"-read",
|
|
1051
|
+
`/Users/${username}`,
|
|
1052
|
+
"RealName"
|
|
1053
|
+
])).split("\n").find((l) => l.trim() && !l.includes("RealName"));
|
|
1054
|
+
if (nameMatch?.trim()) lines.push(`- Real Name (local): ${nameMatch.trim()}`);
|
|
1055
|
+
const mobileMe = await defaultsRead("MobileMeAccounts");
|
|
1056
|
+
const appleIdMatch = mobileMe.match(/AccountID\s*=\s*"?([^";\n]+)/);
|
|
1057
|
+
const displayNameMatch = mobileMe.match(/DisplayName\s*=\s*"?([^";\n]+)/);
|
|
1058
|
+
if (displayNameMatch) lines.push(`- Apple ID Display Name: ${displayNameMatch[1].trim()}`);
|
|
1059
|
+
if (appleIdMatch) lines.push(`- Apple ID: ${appleIdMatch[1].trim()}`);
|
|
1060
|
+
const computerName = await exec("scutil", ["--get", "ComputerName"]);
|
|
1061
|
+
if (computerName) lines.push(`- Computer Name: ${computerName}`);
|
|
1062
|
+
const gitName = await exec("git", [
|
|
1063
|
+
"config",
|
|
1064
|
+
"--global",
|
|
1065
|
+
"user.name"
|
|
1066
|
+
]);
|
|
1067
|
+
const gitEmail = await exec("git", [
|
|
1068
|
+
"config",
|
|
1069
|
+
"--global",
|
|
1070
|
+
"user.email"
|
|
1071
|
+
]);
|
|
1072
|
+
if (gitName) lines.push(`- Git Name: ${gitName}`);
|
|
1073
|
+
if (gitEmail) lines.push(`- Git Email: ${gitEmail}`);
|
|
1074
|
+
lines.push("", "## Locale & Language");
|
|
1075
|
+
const langList = (await defaultsRead("NSGlobalDomain", "AppleLanguages")).match(/"([^"]+)"/g)?.map((s) => s.replace(/"/g, "")) ?? [];
|
|
1076
|
+
if (langList.length > 0) lines.push(`- Languages: ${langList.join(", ")}`);
|
|
1077
|
+
const locale = await defaultsRead("NSGlobalDomain", "AppleLocale");
|
|
1078
|
+
if (locale) lines.push(`- Locale: ${locale}`);
|
|
1079
|
+
const inputSources = await defaultsRead("com.apple.HIToolbox", "AppleSelectedInputSources");
|
|
1080
|
+
const inputMethods = inputSources.match(/"Input Mode"\s*=\s*"([^"]+)"/g) ?? [];
|
|
1081
|
+
const bundleIds = inputSources.match(/"Bundle ID"\s*=\s*"([^"]+)"/g) ?? [];
|
|
1082
|
+
const inputs = [...inputMethods, ...bundleIds].map((s) => s.replace(/.*"([^"]+)"$/, "$1")).filter((s) => !s.includes("PressAndHold"));
|
|
1083
|
+
if (inputs.length > 0) lines.push(`- Input Methods: ${inputs.join(", ")}`);
|
|
1084
|
+
const appearance = await defaultsRead("-g", "AppleInterfaceStyle");
|
|
1085
|
+
lines.push(`- System Appearance: ${appearance || "Light"}`);
|
|
1086
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
1087
|
+
lines.push(`- Timezone: ${tz}`);
|
|
1088
|
+
return {
|
|
1089
|
+
id: "identity-profile",
|
|
1090
|
+
title: "Mac User Identity Profile",
|
|
1091
|
+
content: lines.join("\n")
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
async function collectCalendarContext() {
|
|
1095
|
+
let calNames = [];
|
|
1096
|
+
const raw = await osascript("tell application \"Calendar\" to get name of every calendar");
|
|
1097
|
+
if (raw) calNames = raw.split(", ").map((s) => s.trim()).filter(Boolean);
|
|
1098
|
+
const lines = [
|
|
1099
|
+
"## Calendar Subscriptions",
|
|
1100
|
+
"",
|
|
1101
|
+
`Total calendars: ${calNames.length}`,
|
|
1102
|
+
"",
|
|
1103
|
+
...calNames.map((name) => `- ${name}`)
|
|
1104
|
+
];
|
|
1105
|
+
return {
|
|
1106
|
+
id: "calendar-context",
|
|
1107
|
+
title: "Calendar Context",
|
|
1108
|
+
content: calNames.length > 0 ? lines.join("\n") : "No calendar data accessible (Calendar app may not be running)."
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
async function collectFileOrganization() {
|
|
1112
|
+
const lines = [];
|
|
1113
|
+
const docsDir = path.join(HOME, "Documents");
|
|
1114
|
+
try {
|
|
1115
|
+
const dirs = (await fs.readdir(docsDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
1116
|
+
lines.push("## Documents Folders", "", ...dirs.map((d) => `- ${d}`));
|
|
1117
|
+
} catch {
|
|
1118
|
+
lines.push("## Documents Folders", "", "(not accessible)");
|
|
1119
|
+
}
|
|
1120
|
+
const desktopDir = path.join(HOME, "Desktop");
|
|
1121
|
+
try {
|
|
1122
|
+
const entries = await fs.readdir(desktopDir);
|
|
1123
|
+
lines.push("", "## Desktop Items", "", ...entries.slice(0, 20).map((e) => `- ${e}`));
|
|
1124
|
+
if (entries.length > 20) lines.push(`- ... and ${entries.length - 20} more`);
|
|
1125
|
+
} catch {
|
|
1126
|
+
lines.push("", "## Desktop Items", "", "(not accessible)");
|
|
1127
|
+
}
|
|
1128
|
+
const dlDir = path.join(HOME, "Downloads");
|
|
1129
|
+
try {
|
|
1130
|
+
const entries = await fs.readdir(dlDir);
|
|
1131
|
+
lines.push("", "## Recent Downloads (latest 15 by name)", "", ...entries.slice(0, 15).map((e) => `- ${e}`));
|
|
1132
|
+
} catch {
|
|
1133
|
+
lines.push("", "## Recent Downloads", "", "(not accessible)");
|
|
1134
|
+
}
|
|
1135
|
+
return {
|
|
1136
|
+
id: "file-organization",
|
|
1137
|
+
title: "File Organization Structure",
|
|
1138
|
+
content: lines.join("\n")
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
async function collectDevEnvironment() {
|
|
1142
|
+
const lines = ["## Runtime Versions"];
|
|
1143
|
+
for (const [name, cmd, args] of [
|
|
1144
|
+
[
|
|
1145
|
+
"Node.js",
|
|
1146
|
+
"node",
|
|
1147
|
+
["--version"]
|
|
1148
|
+
],
|
|
1149
|
+
[
|
|
1150
|
+
"Python",
|
|
1151
|
+
"python3",
|
|
1152
|
+
["--version"]
|
|
1153
|
+
],
|
|
1154
|
+
[
|
|
1155
|
+
"Go",
|
|
1156
|
+
"go",
|
|
1157
|
+
["version"]
|
|
1158
|
+
],
|
|
1159
|
+
[
|
|
1160
|
+
"Rust",
|
|
1161
|
+
"rustc",
|
|
1162
|
+
["--version"]
|
|
1163
|
+
],
|
|
1164
|
+
[
|
|
1165
|
+
"Java",
|
|
1166
|
+
"java",
|
|
1167
|
+
["--version"]
|
|
1168
|
+
]
|
|
1169
|
+
]) {
|
|
1170
|
+
const ver = await exec(cmd, args);
|
|
1171
|
+
if (ver) {
|
|
1172
|
+
const first = ver.split("\n")[0] ?? ver;
|
|
1173
|
+
lines.push(`- ${name}: ${first}`);
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
lines.push("", "## Package Managers");
|
|
1177
|
+
for (const pm of [
|
|
1178
|
+
"brew",
|
|
1179
|
+
"pnpm",
|
|
1180
|
+
"npm",
|
|
1181
|
+
"pip3",
|
|
1182
|
+
"cargo"
|
|
1183
|
+
]) {
|
|
1184
|
+
const which = await exec("which", [pm]);
|
|
1185
|
+
if (which) lines.push(`- ${pm}: ${which}`);
|
|
1186
|
+
}
|
|
1187
|
+
const docker = await exec("docker", ["--version"]);
|
|
1188
|
+
if (docker) lines.push("", "## Docker", "", `- ${docker}`);
|
|
1189
|
+
lines.push("", "## Shell");
|
|
1190
|
+
const shell = process.env.SHELL ?? "";
|
|
1191
|
+
lines.push(`- Shell: ${shell}`);
|
|
1192
|
+
const zshrc = await readSafe(path.join(HOME, ".zshrc"));
|
|
1193
|
+
const themeMatch = zshrc.match(/^ZSH_THEME="?([^"\n]+)/m);
|
|
1194
|
+
const pluginsMatch = zshrc.match(/^plugins=\(([^)]+)\)/m);
|
|
1195
|
+
if (themeMatch) lines.push(`- Oh-My-Zsh Theme: ${themeMatch[1]}`);
|
|
1196
|
+
if (pluginsMatch) lines.push(`- Oh-My-Zsh Plugins: ${pluginsMatch[1].trim()}`);
|
|
1197
|
+
const npmGlobal = await exec("npm", [
|
|
1198
|
+
"list",
|
|
1199
|
+
"-g",
|
|
1200
|
+
"--depth=0"
|
|
1201
|
+
]);
|
|
1202
|
+
if (npmGlobal) {
|
|
1203
|
+
const pkgs = npmGlobal.split("\n").filter((l) => l.startsWith("├") || l.startsWith("└")).map((l) => l.replace(/^[├└─┬│\s]+/, "").trim()).filter(Boolean);
|
|
1204
|
+
if (pkgs.length > 0) lines.push("", "## Global npm Packages", "", ...pkgs.map((p) => `- ${p}`));
|
|
1205
|
+
}
|
|
1206
|
+
const casks = await exec("brew", ["list", "--cask"]);
|
|
1207
|
+
if (casks) {
|
|
1208
|
+
const caskList = casks.split("\n").filter(Boolean);
|
|
1209
|
+
lines.push("", "## Homebrew Casks", "", ...caskList.map((c) => `- ${c}`));
|
|
1210
|
+
}
|
|
1211
|
+
return {
|
|
1212
|
+
id: "dev-environment",
|
|
1213
|
+
title: "Development Environment",
|
|
1214
|
+
content: lines.join("\n")
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
async function collectDevPreferences() {
|
|
1218
|
+
const lines = [];
|
|
1219
|
+
const zshrc = await readSafe(path.join(HOME, ".zshrc"));
|
|
1220
|
+
if (zshrc) {
|
|
1221
|
+
const sanitized = stripSecretExports(zshrc);
|
|
1222
|
+
const aliases = sanitized.split("\n").filter((l) => l.match(/^\s*alias\s/));
|
|
1223
|
+
if (aliases.length > 0) lines.push("## Shell Aliases", "", ...aliases);
|
|
1224
|
+
const pathLines = sanitized.split("\n").filter((l) => l.match(/^\s*export\s+PATH/) && !l.includes("REDACTED"));
|
|
1225
|
+
if (pathLines.length > 0) lines.push("", "## PATH Additions", "", ...pathLines);
|
|
1226
|
+
}
|
|
1227
|
+
const gitconfig = await readSafe(path.join(HOME, ".gitconfig"));
|
|
1228
|
+
if (gitconfig) {
|
|
1229
|
+
const sanitized = stripGitCredentials(gitconfig);
|
|
1230
|
+
lines.push("", "## Git Config", "", "```", sanitized.trim(), "```");
|
|
1231
|
+
}
|
|
1232
|
+
const gitAliases = await exec("git", [
|
|
1233
|
+
"config",
|
|
1234
|
+
"--global",
|
|
1235
|
+
"--get-regexp",
|
|
1236
|
+
"alias"
|
|
1237
|
+
]);
|
|
1238
|
+
if (gitAliases) lines.push("", "## Git Aliases", "", ...gitAliases.split("\n").map((l) => `- ${l}`));
|
|
1239
|
+
const defaultBranch = await exec("git", [
|
|
1240
|
+
"config",
|
|
1241
|
+
"--global",
|
|
1242
|
+
"init.defaultBranch"
|
|
1243
|
+
]);
|
|
1244
|
+
if (defaultBranch) lines.push("", `## Default Git Branch: ${defaultBranch}`);
|
|
1245
|
+
const extDir = path.join(HOME, ".cursor", "extensions");
|
|
1246
|
+
try {
|
|
1247
|
+
const exts = (await fs.readdir(extDir)).filter((e) => !e.startsWith(".") && e !== "extensions.json");
|
|
1248
|
+
if (exts.length > 0) lines.push("", "## Cursor Extensions", "", ...exts.map((e) => `- ${e}`));
|
|
1249
|
+
} catch {}
|
|
1250
|
+
return {
|
|
1251
|
+
id: "dev-preferences",
|
|
1252
|
+
title: "Development Preferences",
|
|
1253
|
+
content: lines.join("\n")
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
async function collectShellHabits() {
|
|
1257
|
+
const raw = await readSafe(path.join(HOME, ".zsh_history"));
|
|
1258
|
+
if (!raw) return {
|
|
1259
|
+
id: "shell-habits",
|
|
1260
|
+
title: "Shell Command Habits",
|
|
1261
|
+
content: "No zsh history found."
|
|
1262
|
+
};
|
|
1263
|
+
const rawLines = raw.split("\n");
|
|
1264
|
+
const totalEntries = rawLines.length;
|
|
1265
|
+
const freq = /* @__PURE__ */ new Map();
|
|
1266
|
+
for (const line of rawLines) {
|
|
1267
|
+
const firstWord = line.replace(/^:\s*\d+:\d+;/, "").trim().split(/\s+/)[0];
|
|
1268
|
+
if (firstWord && firstWord.length > 0 && firstWord.length < 50) freq.set(firstWord, (freq.get(firstWord) ?? 0) + 1);
|
|
1269
|
+
}
|
|
1270
|
+
const top30 = [...freq.entries()].sort((a, b) => b[1] - a[1]).slice(0, 30);
|
|
1271
|
+
return {
|
|
1272
|
+
id: "shell-habits",
|
|
1273
|
+
title: "Shell Command Habits",
|
|
1274
|
+
content: [
|
|
1275
|
+
`Total history entries: ${totalEntries}`,
|
|
1276
|
+
"",
|
|
1277
|
+
"## Most Used Commands (Top 30)",
|
|
1278
|
+
"",
|
|
1279
|
+
...top30.map(([cmd, count]) => `- ${cmd}: ${count} times`)
|
|
1280
|
+
].join("\n")
|
|
1281
|
+
};
|
|
1282
|
+
}
|
|
1283
|
+
async function collectCodingRules() {
|
|
1284
|
+
const sections = [];
|
|
1285
|
+
const claudePaths = [path.join(HOME, "CLAUDE.md"), path.join(HOME, ".claude", "CLAUDE.md")];
|
|
1286
|
+
for (const p of claudePaths) {
|
|
1287
|
+
const content = await readSafe(p);
|
|
1288
|
+
if (content) {
|
|
1289
|
+
const relPath = p.replace(HOME, "~");
|
|
1290
|
+
sections.push(`## ${relPath}`, "", content.trim(), "");
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
const cmdDir = path.join(HOME, ".claude", "commands");
|
|
1294
|
+
try {
|
|
1295
|
+
const mdFiles = (await fs.readdir(cmdDir)).filter((e) => e.endsWith(".md"));
|
|
1296
|
+
if (mdFiles.length > 0) {
|
|
1297
|
+
sections.push("## Claude Custom Commands", "");
|
|
1298
|
+
for (const f of mdFiles) {
|
|
1299
|
+
const content = await readSafe(path.join(cmdDir, f));
|
|
1300
|
+
if (content) sections.push(`### ${f}`, "", content.trim(), "");
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
} catch {}
|
|
1304
|
+
const activeRepos = await findRecentGitRepos(30);
|
|
1305
|
+
const projectRules = [];
|
|
1306
|
+
for (const repo of activeRepos.slice(0, 10)) for (const ruleFile of [
|
|
1307
|
+
"CLAUDE.md",
|
|
1308
|
+
"AGENTS.md",
|
|
1309
|
+
".cursorrules"
|
|
1310
|
+
]) {
|
|
1311
|
+
const content = await readSafe(path.join(repo, ruleFile));
|
|
1312
|
+
if (content && content.length > 10) {
|
|
1313
|
+
const repoName = path.basename(repo);
|
|
1314
|
+
projectRules.push(`### ${repoName}/${ruleFile}`, "", content.trim().slice(0, 2e3), "");
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (projectRules.length > 0) sections.push("## Project-Level Rules", "", ...projectRules);
|
|
1318
|
+
const cursorRulesDir = path.join(HOME, ".cursor", "rules");
|
|
1319
|
+
try {
|
|
1320
|
+
const mdFiles = (await fs.readdir(cursorRulesDir)).filter((e) => e.endsWith(".md") || e.endsWith(".mdc"));
|
|
1321
|
+
if (mdFiles.length > 0) {
|
|
1322
|
+
sections.push("## Global Cursor Rules", "");
|
|
1323
|
+
for (const f of mdFiles) {
|
|
1324
|
+
const content = await readSafe(path.join(cursorRulesDir, f));
|
|
1325
|
+
if (content) sections.push(`### ${f}`, "", content.trim().slice(0, 1e3), "");
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
} catch {}
|
|
1329
|
+
return {
|
|
1330
|
+
id: "coding-rules",
|
|
1331
|
+
title: "Coding Rules and AI Agent Config",
|
|
1332
|
+
content: sections.length > 0 ? sections.join("\n") : "No coding rules found."
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
async function collectActiveProjects() {
|
|
1336
|
+
const lines = [];
|
|
1337
|
+
const repos = await findRecentGitRepos(30);
|
|
1338
|
+
if (repos.length > 0) {
|
|
1339
|
+
lines.push("## Active Git Repositories (last 30 days)", "");
|
|
1340
|
+
for (const repo of repos) {
|
|
1341
|
+
const name = repo.replace(HOME, "~");
|
|
1342
|
+
lines.push(`- ${name}`);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const sshConfig = await readSafe(path.join(HOME, ".ssh", "config"));
|
|
1346
|
+
if (sshConfig) {
|
|
1347
|
+
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());
|
|
1348
|
+
if (hosts.length > 0) lines.push("", "## SSH Hosts", "", ...hosts.map((h) => `- ${h}`));
|
|
1349
|
+
}
|
|
1350
|
+
const cloudDir = path.join(HOME, "Library", "CloudStorage");
|
|
1351
|
+
try {
|
|
1352
|
+
const entries = await fs.readdir(cloudDir);
|
|
1353
|
+
if (entries.length > 0) lines.push("", "## Cloud Storage", "", ...entries.map((e) => `- ${e}`));
|
|
1354
|
+
} catch {}
|
|
1355
|
+
return {
|
|
1356
|
+
id: "active-projects",
|
|
1357
|
+
title: "Active Projects and Infrastructure",
|
|
1358
|
+
content: lines.join("\n")
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
async function collectProductivitySetup() {
|
|
1362
|
+
const lines = [];
|
|
1363
|
+
try {
|
|
1364
|
+
const apps = (await fs.readdir("/Applications")).filter((e) => e.endsWith(".app")).map((e) => e.replace(".app", ""));
|
|
1365
|
+
lines.push("## Installed Applications", "", ...apps.map((a) => `- ${a}`));
|
|
1366
|
+
} catch {
|
|
1367
|
+
lines.push("## Installed Applications", "", "(not accessible)");
|
|
1368
|
+
}
|
|
1369
|
+
const dockApps = await defaultsRead("com.apple.dock", "persistent-apps");
|
|
1370
|
+
if (dockApps) {
|
|
1371
|
+
const labels = [...dockApps.matchAll(/"file-label"\s*=\s*"?([^";\n}]+)/g)].map((m) => m[1].trim()).filter(Boolean);
|
|
1372
|
+
if (labels.length > 0) lines.push("", "## Dock Apps (pinned)", "", ...labels.map((a) => `- ${a}`));
|
|
1373
|
+
}
|
|
1374
|
+
const browserMatch = (await defaultsRead("com.apple.LaunchServices/com.apple.launchservices.secure", "LSHandlers")).match(/LSHandlerRoleAll\s*=\s*"([^"]+)"/);
|
|
1375
|
+
if (browserMatch) {
|
|
1376
|
+
const bundleId = browserMatch[1];
|
|
1377
|
+
const browserName = bundleId.includes("edge") ? "Microsoft Edge" : bundleId.includes("chrome") ? "Google Chrome" : bundleId.includes("firefox") ? "Firefox" : bundleId.includes("safari") ? "Safari" : bundleId;
|
|
1378
|
+
lines.push("", `## Default Browser: ${browserName}`);
|
|
1379
|
+
}
|
|
1380
|
+
const resolutions = [...(await exec("system_profiler", ["SPDisplaysDataType"])).matchAll(/Resolution:\s*(.+)/g)].map((m) => m[1].trim());
|
|
1381
|
+
if (resolutions.length > 0) lines.push("", "## Screen Resolution", "", ...resolutions.map((r) => `- ${r}`));
|
|
1382
|
+
return {
|
|
1383
|
+
id: "productivity-setup",
|
|
1384
|
+
title: "Productivity Setup",
|
|
1385
|
+
content: lines.join("\n")
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
async function collectBrowserBookmarks() {
|
|
1389
|
+
const raw = await readSafe(path.join(HOME, "Library", "Application Support", "Microsoft Edge", "Default", "Bookmarks"));
|
|
1390
|
+
if (!raw) return {
|
|
1391
|
+
id: "browser-bookmarks",
|
|
1392
|
+
title: "Browser Bookmark Structure",
|
|
1393
|
+
content: "No Edge bookmarks found."
|
|
1394
|
+
};
|
|
1395
|
+
try {
|
|
1396
|
+
const data = JSON.parse(raw);
|
|
1397
|
+
const folders = [];
|
|
1398
|
+
let totalUrls = 0;
|
|
1399
|
+
function walk(node, depth) {
|
|
1400
|
+
if (node.type === "folder") {
|
|
1401
|
+
const children = node.children ?? [];
|
|
1402
|
+
const name = node.name ?? "";
|
|
1403
|
+
if (children.length > 0) folders.push({
|
|
1404
|
+
name,
|
|
1405
|
+
count: children.length,
|
|
1406
|
+
depth
|
|
1407
|
+
});
|
|
1408
|
+
for (const child of children) walk(child, depth + 1);
|
|
1409
|
+
} else if (node.type === "url") totalUrls++;
|
|
1410
|
+
}
|
|
1411
|
+
const roots = data.roots;
|
|
1412
|
+
for (const root of Object.values(roots)) if (root && typeof root === "object" && "children" in root) walk(root, 0);
|
|
1413
|
+
folders.sort((a, b) => b.count - a.count);
|
|
1414
|
+
return {
|
|
1415
|
+
id: "browser-bookmarks",
|
|
1416
|
+
title: "Browser Bookmark Structure",
|
|
1417
|
+
content: [
|
|
1418
|
+
`Total bookmarks: ${totalUrls}`,
|
|
1419
|
+
`Total folders: ${folders.length}`,
|
|
1420
|
+
"",
|
|
1421
|
+
"## Top Bookmark Folders (by item count)",
|
|
1422
|
+
"",
|
|
1423
|
+
...folders.slice(0, 40).map((f) => `- ${" ".repeat(Math.min(f.depth, 2))}${f.name}: ${f.count} items`)
|
|
1424
|
+
].join("\n")
|
|
1425
|
+
};
|
|
1426
|
+
} catch {
|
|
1427
|
+
return {
|
|
1428
|
+
id: "browser-bookmarks",
|
|
1429
|
+
title: "Browser Bookmark Structure",
|
|
1430
|
+
content: "Failed to parse Edge bookmarks."
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
async function collectBrowserDomains() {
|
|
1435
|
+
const histPath = path.join(HOME, "Library", "Application Support", "Microsoft Edge", "Default", "History");
|
|
1436
|
+
if (!await exists(histPath)) return {
|
|
1437
|
+
id: "browser-domains",
|
|
1438
|
+
title: "Browser Top Domains (30 days)",
|
|
1439
|
+
content: "No Edge history found."
|
|
1440
|
+
};
|
|
1441
|
+
const tmpDb = path.join(os.tmpdir(), `pai-edge-history-${Date.now()}.db`);
|
|
1442
|
+
try {
|
|
1443
|
+
await fs.copyFile(histPath, tmpDb);
|
|
1444
|
+
const output = await exec("sqlite3", [tmpDb, `
|
|
1445
|
+
SELECT
|
|
1446
|
+
REPLACE(REPLACE(SUBSTR(url, 1, INSTR(SUBSTR(url, 9), '/') + 8), 'https://', ''), 'http://', '') as domain,
|
|
1447
|
+
COUNT(*) as visits
|
|
1448
|
+
FROM urls
|
|
1449
|
+
WHERE last_visit_time > (strftime('%s', 'now', '-30 days') + 11644473600) * 1000000
|
|
1450
|
+
GROUP BY domain
|
|
1451
|
+
ORDER BY visits DESC
|
|
1452
|
+
LIMIT 30;
|
|
1453
|
+
`.trim()]);
|
|
1454
|
+
if (!output) return {
|
|
1455
|
+
id: "browser-domains",
|
|
1456
|
+
title: "Browser Top Domains (30 days)",
|
|
1457
|
+
content: "Could not query Edge history (DB may be locked)."
|
|
1458
|
+
};
|
|
1459
|
+
return {
|
|
1460
|
+
id: "browser-domains",
|
|
1461
|
+
title: "Browser Top Domains (30 days)",
|
|
1462
|
+
content: [
|
|
1463
|
+
"## Top 30 Domains (last 30 days)",
|
|
1464
|
+
"",
|
|
1465
|
+
...output.split("\n").map((line) => {
|
|
1466
|
+
const [domain, visits] = line.split("|");
|
|
1467
|
+
return `- ${domain}: ${visits} visits`;
|
|
1468
|
+
})
|
|
1469
|
+
].join("\n")
|
|
1470
|
+
};
|
|
1471
|
+
} finally {
|
|
1472
|
+
try {
|
|
1473
|
+
await fs.unlink(tmpDb);
|
|
1474
|
+
} catch {}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
async function collectGitHubProfile() {
|
|
1478
|
+
const lines = [];
|
|
1479
|
+
if (!await exec("gh", ["--version"])) return {
|
|
1480
|
+
id: "github-profile",
|
|
1481
|
+
title: "GitHub Profile",
|
|
1482
|
+
content: "GitHub CLI (gh) not installed."
|
|
1483
|
+
};
|
|
1484
|
+
if (!await exec("gh", ["auth", "status"])) return {
|
|
1485
|
+
id: "github-profile",
|
|
1486
|
+
title: "GitHub Profile",
|
|
1487
|
+
content: "GitHub CLI not authenticated. Run `gh auth login` to enable."
|
|
1488
|
+
};
|
|
1489
|
+
const userJson = await exec("gh", [
|
|
1490
|
+
"api",
|
|
1491
|
+
"user",
|
|
1492
|
+
"--jq",
|
|
1493
|
+
"[.login, .name, .bio, .company, .location, .blog, .public_repos, .followers, .following, .created_at] | @tsv"
|
|
1494
|
+
]);
|
|
1495
|
+
if (userJson) {
|
|
1496
|
+
const [login, name, bio, company, location, blog, publicRepos, followers, following, createdAt] = userJson.split(" ");
|
|
1497
|
+
lines.push("## GitHub Profile", "");
|
|
1498
|
+
if (login) lines.push(`- Username: ${login}`);
|
|
1499
|
+
if (name) lines.push(`- Name: ${name}`);
|
|
1500
|
+
if (bio) lines.push(`- Bio: ${bio}`);
|
|
1501
|
+
if (company) lines.push(`- Company: ${company}`);
|
|
1502
|
+
if (location) lines.push(`- Location: ${location}`);
|
|
1503
|
+
if (blog) lines.push(`- Website: ${blog}`);
|
|
1504
|
+
if (publicRepos) lines.push(`- Public Repos: ${publicRepos}`);
|
|
1505
|
+
if (followers || following) lines.push(`- Followers/Following: ${followers}/${following}`);
|
|
1506
|
+
if (createdAt) lines.push(`- Member since: ${createdAt}`);
|
|
1507
|
+
}
|
|
1508
|
+
const reposJson = await exec("gh", [
|
|
1509
|
+
"repo",
|
|
1510
|
+
"list",
|
|
1511
|
+
"--limit",
|
|
1512
|
+
"10",
|
|
1513
|
+
"--sort",
|
|
1514
|
+
"updated",
|
|
1515
|
+
"--json",
|
|
1516
|
+
"name,description,primaryLanguage,pushedAt,isPrivate",
|
|
1517
|
+
"--jq",
|
|
1518
|
+
".[] | [.name, .description, (.primaryLanguage.name // \"\"), .pushedAt, .isPrivate] | @tsv"
|
|
1519
|
+
]);
|
|
1520
|
+
if (reposJson) {
|
|
1521
|
+
const repos = reposJson.split("\n").filter(Boolean);
|
|
1522
|
+
if (repos.length > 0) {
|
|
1523
|
+
lines.push("", "## Recent GitHub Repos (by push date)", "");
|
|
1524
|
+
for (const repo of repos) {
|
|
1525
|
+
const [name, desc, lang, _pushed, isPrivate] = repo.split(" ");
|
|
1526
|
+
const visibility = isPrivate === "true" ? "private" : "public";
|
|
1527
|
+
const langTag = lang ? ` [${lang}]` : "";
|
|
1528
|
+
const descTag = desc ? ` — ${desc}` : "";
|
|
1529
|
+
lines.push(`- ${name}${langTag} (${visibility})${descTag}`);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
const starsJson = await exec("gh", [
|
|
1534
|
+
"api",
|
|
1535
|
+
"user/starred?per_page=20&sort=created&direction=desc",
|
|
1536
|
+
"--jq",
|
|
1537
|
+
".[].full_name"
|
|
1538
|
+
]);
|
|
1539
|
+
if (starsJson) {
|
|
1540
|
+
const stars = starsJson.split("\n").filter(Boolean);
|
|
1541
|
+
if (stars.length > 0) {
|
|
1542
|
+
lines.push("", "## Recently Starred Repos (interest signals)", "");
|
|
1543
|
+
lines.push(stars.map((s) => `- ${s}`).join("\n"));
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
return {
|
|
1547
|
+
id: "github-profile",
|
|
1548
|
+
title: "GitHub Profile & Activity",
|
|
1549
|
+
content: lines.length > 0 ? lines.join("\n") : "No GitHub data accessible."
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
async function collectRecentFocus() {
|
|
1553
|
+
const lines = [];
|
|
1554
|
+
const repos = await findRecentGitRepos(14);
|
|
1555
|
+
const commitTopics = /* @__PURE__ */ new Map();
|
|
1556
|
+
const recentMessages = [];
|
|
1557
|
+
for (const repo of repos.slice(0, 8)) {
|
|
1558
|
+
const log = await exec("git", [
|
|
1559
|
+
"-C",
|
|
1560
|
+
repo,
|
|
1561
|
+
"log",
|
|
1562
|
+
"--oneline",
|
|
1563
|
+
"--since=14 days ago",
|
|
1564
|
+
"--format=%s",
|
|
1565
|
+
"-n",
|
|
1566
|
+
"20"
|
|
1567
|
+
]);
|
|
1568
|
+
if (log) for (const msg of log.split("\n").filter(Boolean)) {
|
|
1569
|
+
recentMessages.push(msg);
|
|
1570
|
+
const type = msg.match(/^(feat|fix|refactor|test|docs|chore|perf|ci|build|style)/i);
|
|
1571
|
+
if (type) {
|
|
1572
|
+
const key = type[1].toLowerCase();
|
|
1573
|
+
commitTopics.set(key, (commitTopics.get(key) ?? 0) + 1);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (commitTopics.size > 0) {
|
|
1578
|
+
const sorted = [...commitTopics.entries()].sort((a, b) => b[1] - a[1]);
|
|
1579
|
+
lines.push("## Recent Commit Activity (last 2 weeks)", "");
|
|
1580
|
+
lines.push(`Total commits analyzed: ${recentMessages.length}`);
|
|
1581
|
+
lines.push("", "Commit types:");
|
|
1582
|
+
for (const [type, count] of sorted) lines.push(`- ${type}: ${count}`);
|
|
1583
|
+
}
|
|
1584
|
+
if (recentMessages.length > 0) {
|
|
1585
|
+
lines.push("", "## Recent Commit Messages (sample)", "");
|
|
1586
|
+
const unique = [...new Set(recentMessages)].slice(0, 15);
|
|
1587
|
+
for (const msg of unique) lines.push(`- ${msg}`);
|
|
1588
|
+
}
|
|
1589
|
+
const histRaw = await readSafe(path.join(HOME, ".zsh_history"));
|
|
1590
|
+
if (histRaw) {
|
|
1591
|
+
const recent = histRaw.split("\n").slice(-200);
|
|
1592
|
+
const cdPaths = /* @__PURE__ */ new Map();
|
|
1593
|
+
const tools = /* @__PURE__ */ new Map();
|
|
1594
|
+
for (const line of recent) {
|
|
1595
|
+
const cmd = line.replace(/^:\s*\d+:\d+;/, "").trim();
|
|
1596
|
+
const cdMatch = cmd.match(/^cd\s+(.+)/);
|
|
1597
|
+
if (cdMatch) {
|
|
1598
|
+
const dest = cdMatch[1].trim().replace(/^~/, HOME);
|
|
1599
|
+
cdPaths.set(dest, (cdPaths.get(dest) ?? 0) + 1);
|
|
1600
|
+
}
|
|
1601
|
+
const toolMatch = cmd.match(/^(docker|kubectl|terraform|aws|gcloud|firebase|vercel|netlify|npm|pnpm|yarn|bun|cargo|go|python|pip|uv)\b/);
|
|
1602
|
+
if (toolMatch) tools.set(toolMatch[1], (tools.get(toolMatch[1]) ?? 0) + 1);
|
|
1603
|
+
}
|
|
1604
|
+
if (cdPaths.size > 0) {
|
|
1605
|
+
const topDirs = [...cdPaths.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
1606
|
+
lines.push("", "## Recent Working Directories", "");
|
|
1607
|
+
for (const [dir, count] of topDirs) lines.push(`- ${dir.replace(HOME, "~")}: ${count}x`);
|
|
1608
|
+
}
|
|
1609
|
+
if (tools.size > 0) {
|
|
1610
|
+
const topTools = [...tools.entries()].sort((a, b) => b[1] - a[1]);
|
|
1611
|
+
lines.push("", "## Recently Used Tools (from history)", "");
|
|
1612
|
+
for (const [tool, count] of topTools) lines.push(`- ${tool}: ${count}x`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return {
|
|
1616
|
+
id: "recent-focus",
|
|
1617
|
+
title: "Recent Focus & Activity",
|
|
1618
|
+
content: lines.length > 0 ? lines.join("\n") : "No recent activity data found."
|
|
1619
|
+
};
|
|
1620
|
+
}
|
|
1621
|
+
async function collectSocialProfiles() {
|
|
1622
|
+
const lines = [];
|
|
1623
|
+
const npmrc = await readSafe(path.join(HOME, ".npmrc"));
|
|
1624
|
+
if (npmrc) {
|
|
1625
|
+
const registryMatch = npmrc.match(/^registry\s*=\s*(.+)/m);
|
|
1626
|
+
if (registryMatch) lines.push(`- npm registry: ${registryMatch[1].trim()}`);
|
|
1627
|
+
const scopes = npmrc.match(/^@[\w-]+:registry/gm);
|
|
1628
|
+
if (scopes) for (const s of scopes) lines.push(`- npm scope: ${s.split(":")[0]}`);
|
|
1629
|
+
}
|
|
1630
|
+
const npmUser = await exec("npm", ["whoami"]);
|
|
1631
|
+
if (npmUser) lines.push(`- npm username: ${npmUser}`);
|
|
1632
|
+
const pypirc = await readSafe(path.join(HOME, ".pypirc"));
|
|
1633
|
+
if (pypirc) {
|
|
1634
|
+
const repoMatch = pypirc.match(/repository\s*=\s*(.+)/);
|
|
1635
|
+
if (repoMatch) lines.push(`- PyPI repository: ${repoMatch[1].trim()}`);
|
|
1636
|
+
const usernameMatch = pypirc.match(/username\s*=\s*(.+)/);
|
|
1637
|
+
if (usernameMatch) lines.push(`- PyPI username: ${usernameMatch[1].trim()}`);
|
|
1638
|
+
}
|
|
1639
|
+
const dockerConfig = await readSafe(path.join(HOME, ".docker", "config.json"));
|
|
1640
|
+
if (dockerConfig) try {
|
|
1641
|
+
const cfg = JSON.parse(dockerConfig);
|
|
1642
|
+
const auths = Object.keys(cfg.auths ?? {});
|
|
1643
|
+
if (auths.length > 0) lines.push(`- Docker registries: ${auths.join(", ")}`);
|
|
1644
|
+
} catch {}
|
|
1645
|
+
const cargoConfig = await readSafe(path.join(HOME, ".cargo", "config.toml"));
|
|
1646
|
+
if (cargoConfig) {
|
|
1647
|
+
const registries = cargoConfig.match(/\[registries\.(\w+)\]/g);
|
|
1648
|
+
if (registries) lines.push(`- Cargo registries: ${registries.map((r) => r.replace(/\[registries\.|\]/g, "")).join(", ")}`);
|
|
1649
|
+
}
|
|
1650
|
+
const awsIdentity = await exec("aws", [
|
|
1651
|
+
"sts",
|
|
1652
|
+
"get-caller-identity",
|
|
1653
|
+
"--query",
|
|
1654
|
+
"Account",
|
|
1655
|
+
"--output",
|
|
1656
|
+
"text"
|
|
1657
|
+
]);
|
|
1658
|
+
if (awsIdentity) lines.push(`- AWS Account: ${awsIdentity}`);
|
|
1659
|
+
const gcpProject = await exec("gcloud", [
|
|
1660
|
+
"config",
|
|
1661
|
+
"get-value",
|
|
1662
|
+
"project"
|
|
1663
|
+
]);
|
|
1664
|
+
if (gcpProject && !gcpProject.includes("unset")) lines.push(`- GCP Project: ${gcpProject}`);
|
|
1665
|
+
const vercelUser = await exec("vercel", ["whoami"]);
|
|
1666
|
+
if (vercelUser) lines.push(`- Vercel: ${vercelUser}`);
|
|
1667
|
+
return {
|
|
1668
|
+
id: "social-profiles",
|
|
1669
|
+
title: "Registry & Cloud Profiles",
|
|
1670
|
+
content: lines.length > 0 ? [
|
|
1671
|
+
"## Registry & Cloud Accounts",
|
|
1672
|
+
"",
|
|
1673
|
+
...lines
|
|
1674
|
+
].join("\n") : "No registry or cloud profiles detected."
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
/** Find git repos under ~/Documents modified in the last N days */
|
|
1678
|
+
async function findRecentGitRepos(days) {
|
|
1679
|
+
const output = await exec("find", [
|
|
1680
|
+
path.join(HOME, "Documents"),
|
|
1681
|
+
"-maxdepth",
|
|
1682
|
+
"4",
|
|
1683
|
+
"-name",
|
|
1684
|
+
".git",
|
|
1685
|
+
"-type",
|
|
1686
|
+
"d",
|
|
1687
|
+
"-mtime",
|
|
1688
|
+
`-${days}`
|
|
1689
|
+
]);
|
|
1690
|
+
if (!output) return [];
|
|
1691
|
+
return output.split("\n").filter(Boolean).map((p) => p.replace(/\/.git$/, ""));
|
|
1692
|
+
}
|
|
1693
|
+
/** All available Mac collectors in execution order */
|
|
1694
|
+
const ALL_COLLECTORS = [
|
|
1695
|
+
collectIdentityProfile,
|
|
1696
|
+
collectCalendarContext,
|
|
1697
|
+
collectFileOrganization,
|
|
1698
|
+
collectDevEnvironment,
|
|
1699
|
+
collectDevPreferences,
|
|
1700
|
+
collectShellHabits,
|
|
1701
|
+
collectCodingRules,
|
|
1702
|
+
collectActiveProjects,
|
|
1703
|
+
collectProductivitySetup,
|
|
1704
|
+
collectBrowserBookmarks,
|
|
1705
|
+
collectBrowserDomains,
|
|
1706
|
+
collectGitHubProfile,
|
|
1707
|
+
collectRecentFocus,
|
|
1708
|
+
collectSocialProfiles
|
|
1709
|
+
];
|
|
1710
|
+
|
|
1711
|
+
//#endregion
|
|
1712
|
+
//#region src/profile/index.ts
|
|
1713
|
+
/**
|
|
1714
|
+
* Profile module — compile, load, and rebuild user profiles.
|
|
1715
|
+
*/
|
|
1716
|
+
/** Load the current profile from disk. Returns null if not found. */
|
|
1717
|
+
async function loadProfile() {
|
|
1718
|
+
try {
|
|
1719
|
+
return await fs.readFile(getProfilePath(), "utf-8");
|
|
1720
|
+
} catch {
|
|
1721
|
+
return null;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
/** Save profile markdown to disk. */
|
|
1725
|
+
async function saveProfile(content) {
|
|
1726
|
+
const profilePath = getProfilePath();
|
|
1727
|
+
await fs.writeFile(profilePath, content, "utf-8");
|
|
1728
|
+
return profilePath;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Full rebuild: scan local machine → compile profile → write to disk.
|
|
1732
|
+
* Also writes raw files for the scan data (for vault/distill pipeline compatibility).
|
|
1733
|
+
*/
|
|
1734
|
+
async function rebuildProfile(opts) {
|
|
1735
|
+
const verbose = opts?.verbose ?? true;
|
|
1736
|
+
const spin = verbose ? spinner("Scanning local machine...") : null;
|
|
1737
|
+
const settled = await Promise.allSettled(ALL_COLLECTORS.map((collector) => collector()));
|
|
1738
|
+
const results = [];
|
|
1739
|
+
const failed = [];
|
|
1740
|
+
for (let i = 0; i < settled.length; i++) {
|
|
1741
|
+
const s = settled[i];
|
|
1742
|
+
if (s.status === "fulfilled") results.push(s.value);
|
|
1743
|
+
else {
|
|
1744
|
+
const name = ALL_COLLECTORS[i].name;
|
|
1745
|
+
failed.push(name);
|
|
1746
|
+
if (verbose) warn(`Collector ${name} failed: ${String(s.reason)}`);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
spin?.succeed(`Scanned ${results.length} sources (${failed.length} failed)`);
|
|
1750
|
+
if (!opts?.skipRaw) for (const entry of results) try {
|
|
1751
|
+
await addConnectorEntry("mac", entry);
|
|
1752
|
+
} catch {}
|
|
1753
|
+
return {
|
|
1754
|
+
profilePath: await saveProfile(compileProfile(results)),
|
|
1755
|
+
results
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
//#endregion
|
|
1760
|
+
export { addFile, addText, addUrl, compileProfile, distillPipeline, generateAll, generateProfile, listPending, llmCall, loadConfig, loadProfile, loadProfiles, rebuildProfile, scrapeUrl, search, updateIndex };
|
|
1761
|
+
//# sourceMappingURL=index.mjs.map
|