@nguyentamdat/mempalace 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+
5
+ import { cancel, confirm, isCancel, select, text } from "@clack/prompts";
6
+
7
+ import { type DetectedEntity, detectEntities, scanForDetection } from "./entity-detector";
8
+ import { COMMON_ENGLISH_WORDS, EntityRegistry } from "./entity-registry";
9
+
10
+ export const DEFAULT_WINGS = {
11
+ work: ["projects", "clients", "team", "decisions", "research"],
12
+ personal: ["family", "health", "creative", "reflections", "relationships"],
13
+ combo: ["family", "work", "health", "creative", "projects", "reflections"],
14
+ } as const;
15
+
16
+ type OnboardingMode = keyof typeof DEFAULT_WINGS;
17
+ type PersonContext = "personal" | "work";
18
+
19
+ type OnboardingPerson = {
20
+ name: string;
21
+ relationship: string;
22
+ context: PersonContext;
23
+ };
24
+
25
+ type BootstrapOptions = {
26
+ people: OnboardingPerson[];
27
+ projects: string[];
28
+ wings: string[];
29
+ mode: OnboardingMode;
30
+ configDir?: string;
31
+ };
32
+
33
+ function ensureNotCancelled<T>(value: T): T {
34
+ if (isCancel(value)) {
35
+ cancel("Operation cancelled.");
36
+ process.exit(1);
37
+ }
38
+
39
+ return value;
40
+ }
41
+
42
+ function hr(): void {
43
+ console.log(`\n${"─".repeat(58)}`);
44
+ }
45
+
46
+ function header(message: string): void {
47
+ console.log(`\n${"=".repeat(58)}`);
48
+ console.log(` ${message}`);
49
+ console.log(`${"=".repeat(58)}`);
50
+ }
51
+
52
+ async function promptText(message: string, placeholder?: string, defaultValue?: string): Promise<string> {
53
+ const value = ensureNotCancelled(
54
+ await text({
55
+ message,
56
+ placeholder,
57
+ defaultValue,
58
+ }),
59
+ );
60
+
61
+ if (typeof value !== "string") {
62
+ return "";
63
+ }
64
+
65
+ return value.trim();
66
+ }
67
+
68
+ async function promptConfirm(message: string, initialValue = false): Promise<boolean> {
69
+ return ensureNotCancelled(
70
+ await confirm({
71
+ message,
72
+ initialValue,
73
+ }),
74
+ ) as boolean;
75
+ }
76
+
77
+ function parsePersonEntry(entry: string): { name: string; relationship: string } {
78
+ const [namePart, relationshipPart] = entry.split(",", 2).map((part) => part.trim());
79
+ return {
80
+ name: namePart ?? "",
81
+ relationship: relationshipPart ?? "",
82
+ };
83
+ }
84
+
85
+ export async function askMode(): Promise<OnboardingMode> {
86
+ header("Welcome to MemPalace");
87
+ console.log(`
88
+ MemPalace is a personal memory system. To work well, it needs to know
89
+ a little about your world — who the people are, what the projects
90
+ are, and how you want your memory organized.
91
+
92
+ This takes about 2 minutes. You can always update it later.
93
+ `);
94
+
95
+ return ensureNotCancelled(
96
+ await select<OnboardingMode>({
97
+ message: "How are you using MemPalace?",
98
+ options: [
99
+ { value: "work", label: "Work", hint: "notes, projects, clients, colleagues, decisions" },
100
+ { value: "personal", label: "Personal", hint: "diary, family, health, relationships, reflections" },
101
+ { value: "combo", label: "Both", hint: "personal and professional mixed" },
102
+ ],
103
+ }),
104
+ ) as OnboardingMode;
105
+ }
106
+
107
+ export async function askPeople(mode: OnboardingMode): Promise<[OnboardingPerson[], Record<string, string>]> {
108
+ const people: OnboardingPerson[] = [];
109
+ const aliases: Record<string, string> = {};
110
+
111
+ if (mode === "personal" || mode === "combo") {
112
+ hr();
113
+ console.log(`
114
+ Personal world — who are the important people in your life?
115
+
116
+ Format: name, relationship (e.g. "Riley, daughter" or just "Devon")
117
+ For nicknames, you'll be asked separately.
118
+ Leave blank or type 'done' when finished.
119
+ `);
120
+
121
+ while (true) {
122
+ const entry = await promptText("Person", "Riley, daughter");
123
+ if (!entry || entry.toLowerCase() === "done") {
124
+ break;
125
+ }
126
+
127
+ const { name, relationship } = parsePersonEntry(entry);
128
+ if (!name) {
129
+ continue;
130
+ }
131
+
132
+ const nickname = await promptText(`Nickname for ${name}?`, "leave blank to skip");
133
+ if (nickname) {
134
+ aliases[nickname] = name;
135
+ }
136
+
137
+ people.push({ name, relationship, context: "personal" });
138
+ }
139
+ }
140
+
141
+ if (mode === "work" || mode === "combo") {
142
+ hr();
143
+ console.log(`
144
+ Work world — who are the colleagues, clients, or collaborators
145
+ you'd want to find in your notes?
146
+
147
+ Format: name, role (e.g. "Ben, co-founder" or just "Sarah")
148
+ Leave blank or type 'done' when finished.
149
+ `);
150
+
151
+ while (true) {
152
+ const entry = await promptText("Person", "Ben, co-founder");
153
+ if (!entry || entry.toLowerCase() === "done") {
154
+ break;
155
+ }
156
+
157
+ const { name, relationship } = parsePersonEntry(entry);
158
+ if (!name) {
159
+ continue;
160
+ }
161
+
162
+ people.push({ name, relationship, context: "work" });
163
+ }
164
+ }
165
+
166
+ return [people, aliases];
167
+ }
168
+
169
+ export async function askProjects(mode: OnboardingMode): Promise<string[]> {
170
+ if (mode === "personal") {
171
+ return [];
172
+ }
173
+
174
+ hr();
175
+ console.log(`
176
+ What are your main projects? (These help MemPalace distinguish project
177
+ names from person names — e.g. "Lantern" the project vs. "Lantern" the word.)
178
+
179
+ Leave blank or type 'done' when finished.
180
+ `);
181
+
182
+ const projects: string[] = [];
183
+ while (true) {
184
+ const project = await promptText("Project", "Lantern");
185
+ if (!project || project.toLowerCase() === "done") {
186
+ break;
187
+ }
188
+
189
+ projects.push(project);
190
+ }
191
+
192
+ return projects;
193
+ }
194
+
195
+ export async function askWings(mode: OnboardingMode): Promise<string[]> {
196
+ const defaults = [...DEFAULT_WINGS[mode]];
197
+ hr();
198
+ console.log(`
199
+ Wings are the top-level categories in your memory palace.
200
+
201
+ Suggested wings for ${mode} mode:
202
+ ${defaults.join(", ")}
203
+ `);
204
+
205
+ const keepDefaults = await promptConfirm("Use these suggested wings?", true);
206
+ if (keepDefaults) {
207
+ return defaults;
208
+ }
209
+
210
+ const custom = await promptText("Custom wings (comma-separated)", "family, work, health");
211
+ const wings = custom
212
+ .split(",")
213
+ .map((wing) => wing.trim())
214
+ .filter((wing) => wing.length > 0);
215
+
216
+ return wings.length > 0 ? wings : defaults;
217
+ }
218
+
219
+ export function autoDetect(directory: string, knownPeople: OnboardingPerson[]): DetectedEntity[] {
220
+ const knownNames = new Set(knownPeople.map((person) => person.name.toLowerCase()));
221
+
222
+ try {
223
+ const files = scanForDetection(directory);
224
+ if (files.length === 0) {
225
+ return [];
226
+ }
227
+
228
+ return detectEntities(files).people.filter(
229
+ (entity) => entity.confidence >= 0.7 && !knownNames.has(entity.name.toLowerCase()),
230
+ );
231
+ } catch {
232
+ return [];
233
+ }
234
+ }
235
+
236
+ export function warnAmbiguous(people: OnboardingPerson[]): string[] {
237
+ return people
238
+ .map((person) => person.name)
239
+ .filter((name) => COMMON_ENGLISH_WORDS.has(name.toLowerCase()));
240
+ }
241
+
242
+ function makeEntityCode(name: string, usedCodes: Set<string>, prefixLength: number): string {
243
+ const compactName = name.replace(/\s+/g, "");
244
+ const upper = compactName.toUpperCase();
245
+
246
+ for (let length = Math.min(prefixLength, upper.length); length <= upper.length; length += 1) {
247
+ const candidate = upper.slice(0, length);
248
+ if (!usedCodes.has(candidate)) {
249
+ usedCodes.add(candidate);
250
+ return candidate;
251
+ }
252
+ }
253
+
254
+ let counter = 2;
255
+ const base = upper.slice(0, Math.max(prefixLength, 1));
256
+ while (true) {
257
+ const candidate = `${base}${counter}`;
258
+ if (!usedCodes.has(candidate)) {
259
+ usedCodes.add(candidate);
260
+ return candidate;
261
+ }
262
+ counter += 1;
263
+ }
264
+ }
265
+
266
+ export function generateAaakBootstrap({ people, projects, wings, mode, configDir }: BootstrapOptions): void {
267
+ const mempalaceDir = configDir ? resolve(configDir) : join(homedir(), ".mempalace");
268
+ mkdirSync(mempalaceDir, { recursive: true });
269
+
270
+ const entityCodes = new Map<string, string>();
271
+ const usedCodes = new Set<string>();
272
+ for (const person of people) {
273
+ entityCodes.set(person.name, makeEntityCode(person.name, usedCodes, 3));
274
+ }
275
+
276
+ const registryLines = [
277
+ "# AAAK Entity Registry",
278
+ "# Auto-generated by mempalace init. Update as needed.",
279
+ "",
280
+ "## People",
281
+ ];
282
+
283
+ for (const person of people) {
284
+ const code = entityCodes.get(person.name);
285
+ if (!code) {
286
+ continue;
287
+ }
288
+
289
+ registryLines.push(
290
+ person.relationship ? ` ${code}=${person.name} (${person.relationship})` : ` ${code}=${person.name}`,
291
+ );
292
+ }
293
+
294
+ if (projects.length > 0) {
295
+ registryLines.push("", "## Projects");
296
+ for (const project of projects) {
297
+ registryLines.push(` ${makeEntityCode(project, usedCodes, 4)}=${project}`);
298
+ }
299
+ }
300
+
301
+ registryLines.push(
302
+ "",
303
+ "## AAAK Quick Reference",
304
+ " Symbols: ♡=love ★=importance ⚠=warning →=relationship |=separator",
305
+ " Structure: KEY:value | GROUP(details) | entity.attribute",
306
+ " Read naturally — expand codes, treat *markers* as emotional context.",
307
+ );
308
+
309
+ writeFileSync(join(mempalaceDir, "aaak_entities.md"), registryLines.join("\n"));
310
+
311
+ const factsLines = [
312
+ "# Critical Facts (bootstrap — will be enriched after mining)",
313
+ "",
314
+ ];
315
+
316
+ const personalPeople = people.filter((person) => person.context === "personal");
317
+ const workPeople = people.filter((person) => person.context === "work");
318
+
319
+ if (personalPeople.length > 0) {
320
+ factsLines.push("## People (personal)");
321
+ for (const person of personalPeople) {
322
+ const code = entityCodes.get(person.name);
323
+ if (!code) {
324
+ continue;
325
+ }
326
+
327
+ factsLines.push(
328
+ person.relationship
329
+ ? `- **${person.name}** (${code}) — ${person.relationship}`
330
+ : `- **${person.name}** (${code})`,
331
+ );
332
+ }
333
+ factsLines.push("");
334
+ }
335
+
336
+ if (workPeople.length > 0) {
337
+ factsLines.push("## People (work)");
338
+ for (const person of workPeople) {
339
+ const code = entityCodes.get(person.name);
340
+ if (!code) {
341
+ continue;
342
+ }
343
+
344
+ factsLines.push(
345
+ person.relationship
346
+ ? `- **${person.name}** (${code}) — ${person.relationship}`
347
+ : `- **${person.name}** (${code})`,
348
+ );
349
+ }
350
+ factsLines.push("");
351
+ }
352
+
353
+ if (projects.length > 0) {
354
+ factsLines.push("## Projects");
355
+ for (const project of projects) {
356
+ factsLines.push(`- **${project}**`);
357
+ }
358
+ factsLines.push("");
359
+ }
360
+
361
+ factsLines.push(
362
+ "## Palace",
363
+ `Wings: ${wings.join(", ")}`,
364
+ `Mode: ${mode}`,
365
+ "",
366
+ "*This file will be enriched by palace_facts.py after mining.*",
367
+ );
368
+
369
+ writeFileSync(join(mempalaceDir, "critical_facts.md"), factsLines.join("\n"));
370
+ }
371
+
372
+ export async function runOnboarding(
373
+ directory = ".",
374
+ configDir?: string,
375
+ autoDetectEnabled = true,
376
+ ): Promise<EntityRegistry> {
377
+ const mode = await askMode();
378
+ const [people, aliases] = await askPeople(mode);
379
+ const projects = await askProjects(mode);
380
+ const wings = await askWings(mode);
381
+
382
+ if (autoDetectEnabled && (await promptConfirm("Scan your files for additional names we might have missed?", true))) {
383
+ const scanDirectoryInput = await promptText("Directory to scan", directory, directory);
384
+ const scanDirectory = scanDirectoryInput || directory;
385
+ const detected = autoDetect(scanDirectory, people);
386
+
387
+ if (detected.length > 0) {
388
+ hr();
389
+ console.log(`\n Found ${detected.length} additional name candidates:\n`);
390
+ for (const entity of detected) {
391
+ console.log(
392
+ ` ${entity.name.padEnd(20, " ")} confidence=${(entity.confidence * 100).toFixed(0)}% (${entity.signals[0] ?? "detected from files"})`,
393
+ );
394
+ }
395
+
396
+ if (await promptConfirm("Review and add any of these to your registry?", true)) {
397
+ for (const entity of detected) {
398
+ const choice = ensureNotCancelled(
399
+ await select<"person" | "skip">({
400
+ message: `${entity.name} — add to your registry?`,
401
+ options: [
402
+ { value: "person", label: "Add as person" },
403
+ { value: "skip", label: "Skip" },
404
+ ],
405
+ }),
406
+ ) as "person" | "skip";
407
+
408
+ if (choice === "skip") {
409
+ continue;
410
+ }
411
+
412
+ const relationship = await promptText(`Relationship/role for ${entity.name}?`, "friend, client, collaborator");
413
+ const context =
414
+ mode === "personal"
415
+ ? "personal"
416
+ : mode === "work"
417
+ ? "work"
418
+ : (ensureNotCancelled(
419
+ await select<PersonContext>({
420
+ message: `${entity.name} — which context fits best?`,
421
+ options: [
422
+ { value: "personal", label: "Personal" },
423
+ { value: "work", label: "Work" },
424
+ ],
425
+ }),
426
+ ) as PersonContext);
427
+
428
+ people.push({
429
+ name: entity.name,
430
+ relationship,
431
+ context,
432
+ });
433
+ }
434
+ }
435
+ }
436
+ }
437
+
438
+ const ambiguous = warnAmbiguous(people);
439
+ if (ambiguous.length > 0) {
440
+ hr();
441
+ console.log(`
442
+ Heads up — these names are also common English words:
443
+ ${ambiguous.join(", ")}
444
+
445
+ MemPalace will check the context before treating them as person names.
446
+ For example: "I picked up Riley" → person.
447
+ "Have you ever tried" → adverb.
448
+ `);
449
+ }
450
+
451
+ const registry = EntityRegistry.load(configDir);
452
+ registry.seed(mode, people, projects, aliases);
453
+
454
+ generateAaakBootstrap({
455
+ people,
456
+ projects,
457
+ wings,
458
+ mode,
459
+ configDir,
460
+ });
461
+
462
+ header("Setup Complete");
463
+ console.log();
464
+ console.log(` ${registry.summary().replaceAll("\n", "\n ")}`);
465
+ console.log(`\n Wings: ${wings.join(", ")}`);
466
+ console.log(`\n Registry saved to: ${join(configDir ?? join(homedir(), ".mempalace"), "entity_registry.json")}`);
467
+ console.log(`\n AAAK entity registry: ${join(configDir ?? join(homedir(), ".mempalace"), "aaak_entities.md")}`);
468
+ console.log(` Critical facts bootstrap: ${join(configDir ?? join(homedir(), ".mempalace"), "critical_facts.md")}`);
469
+ console.log("\n Your AI will know your world from the first session.");
470
+ console.log();
471
+
472
+ return registry;
473
+ }
474
+
475
+ export function quickSetup(
476
+ mode: OnboardingMode,
477
+ people: OnboardingPerson[],
478
+ projects: string[] = [],
479
+ aliases: Record<string, string> = {},
480
+ configDir?: string,
481
+ ): EntityRegistry {
482
+ const registry = EntityRegistry.load(configDir);
483
+ registry.seed(mode, people, projects, aliases);
484
+ return registry;
485
+ }