portable-agent-layer 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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/assets/agents/claude-researcher.md +43 -0
  4. package/assets/agents/investigative-researcher.md +44 -0
  5. package/assets/agents/multi-perspective-researcher.md +43 -0
  6. package/assets/skills/analyze-pdf.md +40 -0
  7. package/assets/skills/analyze-youtube.md +35 -0
  8. package/assets/skills/council.md +43 -0
  9. package/assets/skills/create-skill.md +31 -0
  10. package/assets/skills/extract-entities.md +63 -0
  11. package/assets/skills/extract-wisdom.md +18 -0
  12. package/assets/skills/first-principles.md +17 -0
  13. package/assets/skills/fyzz-chat-api.md +43 -0
  14. package/assets/skills/reflect.md +87 -0
  15. package/assets/skills/research.md +68 -0
  16. package/assets/skills/review.md +19 -0
  17. package/assets/skills/summarize.md +15 -0
  18. package/assets/templates/AGENTS.md.template +45 -0
  19. package/assets/templates/telos/BELIEFS.md +4 -0
  20. package/assets/templates/telos/CHALLENGES.md +4 -0
  21. package/assets/templates/telos/GOALS.md +12 -0
  22. package/assets/templates/telos/IDEAS.md +4 -0
  23. package/assets/templates/telos/IDENTITY.md +4 -0
  24. package/assets/templates/telos/LEARNED.md +4 -0
  25. package/assets/templates/telos/MISSION.md +4 -0
  26. package/assets/templates/telos/MODELS.md +4 -0
  27. package/assets/templates/telos/NARRATIVES.md +4 -0
  28. package/assets/templates/telos/PROJECTS.md +7 -0
  29. package/assets/templates/telos/STRATEGIES.md +4 -0
  30. package/bin/pal +24 -0
  31. package/bin/pal.bat +8 -0
  32. package/bin/pal.ps1 +30 -0
  33. package/package.json +82 -0
  34. package/src/cli/index.ts +344 -0
  35. package/src/cli/install.ts +86 -0
  36. package/src/cli/uninstall.ts +45 -0
  37. package/src/hooks/LoadContext.ts +41 -0
  38. package/src/hooks/SecurityValidator.ts +52 -0
  39. package/src/hooks/SkillGuard.ts +41 -0
  40. package/src/hooks/StopOrchestrator.ts +35 -0
  41. package/src/hooks/UserPromptOrchestrator.ts +35 -0
  42. package/src/hooks/handlers/backup.ts +41 -0
  43. package/src/hooks/handlers/failure.ts +136 -0
  44. package/src/hooks/handlers/rating.ts +409 -0
  45. package/src/hooks/handlers/relationship.ts +113 -0
  46. package/src/hooks/handlers/session-name.ts +121 -0
  47. package/src/hooks/handlers/synthesis.ts +109 -0
  48. package/src/hooks/handlers/tab.ts +8 -0
  49. package/src/hooks/handlers/update-counts.ts +151 -0
  50. package/src/hooks/handlers/work-learning.ts +183 -0
  51. package/src/hooks/handlers/work-session.ts +58 -0
  52. package/src/hooks/lib/claude-md.ts +121 -0
  53. package/src/hooks/lib/context.ts +433 -0
  54. package/src/hooks/lib/entities.ts +304 -0
  55. package/src/hooks/lib/export.ts +76 -0
  56. package/src/hooks/lib/inference.ts +91 -0
  57. package/src/hooks/lib/learning-category.ts +14 -0
  58. package/src/hooks/lib/log.ts +53 -0
  59. package/src/hooks/lib/models.ts +16 -0
  60. package/src/hooks/lib/paths.ts +80 -0
  61. package/src/hooks/lib/relationship.ts +135 -0
  62. package/src/hooks/lib/security.ts +122 -0
  63. package/src/hooks/lib/session-names.ts +247 -0
  64. package/src/hooks/lib/setup.ts +189 -0
  65. package/src/hooks/lib/signal-trends.ts +117 -0
  66. package/src/hooks/lib/signals.ts +37 -0
  67. package/src/hooks/lib/stdin.ts +18 -0
  68. package/src/hooks/lib/stop.ts +155 -0
  69. package/src/hooks/lib/time.ts +19 -0
  70. package/src/hooks/lib/token-usage.ts +42 -0
  71. package/src/hooks/lib/transcript.ts +76 -0
  72. package/src/hooks/lib/wisdom.ts +48 -0
  73. package/src/hooks/lib/work-tracking.ts +193 -0
  74. package/src/hooks/setup-check.ts +42 -0
  75. package/src/targets/claude/install.ts +145 -0
  76. package/src/targets/claude/uninstall.ts +101 -0
  77. package/src/targets/lib.ts +337 -0
  78. package/src/targets/opencode/install.ts +59 -0
  79. package/src/targets/opencode/plugin.ts +328 -0
  80. package/src/targets/opencode/uninstall.ts +57 -0
  81. package/src/tools/entity-save.ts +110 -0
  82. package/src/tools/export.ts +34 -0
  83. package/src/tools/fyzz-api.ts +104 -0
  84. package/src/tools/import.ts +123 -0
  85. package/src/tools/pattern-synthesis.ts +435 -0
  86. package/src/tools/pdf-download.ts +102 -0
  87. package/src/tools/relationship-reflect.ts +362 -0
  88. package/src/tools/session-summary.ts +206 -0
  89. package/src/tools/token-cost.ts +301 -0
  90. package/src/tools/youtube-analyze.ts +105 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Relationship Memory — daily interaction notes tracking preferences,
3
+ * frustrations, positive signals, and milestones.
4
+ *
5
+ * Notes live at memory/relationship/YYYY-MM/YYYY-MM-DD.md
6
+ * W = world (facts about user's situation)
7
+ * O = opinion (preference with confidence)
8
+ *
9
+ * Extraction is handled by the relationship handler via Haiku inference.
10
+ * This lib provides storage and reading utilities only.
11
+ */
12
+
13
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
+ import { resolve } from "node:path";
15
+ import { ensureDir, paths } from "./paths";
16
+
17
+ export type NoteType = "W" | "O";
18
+
19
+ export interface RelationshipNote {
20
+ type: NoteType;
21
+ text: string;
22
+ confidence?: number;
23
+ }
24
+
25
+ function dailyFilePath(date: Date): string {
26
+ const yyyy = date.getFullYear();
27
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
28
+ const dd = String(date.getDate()).padStart(2, "0");
29
+ const monthDir = ensureDir(resolve(paths.relationship(), `${yyyy}-${mm}`));
30
+ return resolve(monthDir, `${yyyy}-${mm}-${dd}.md`);
31
+ }
32
+
33
+ /** Check if a session already has notes in today's file */
34
+ export function hasSessionNotes(sessionId: string): boolean {
35
+ const filepath = dailyFilePath(new Date());
36
+ if (!existsSync(filepath)) return false;
37
+ try {
38
+ const content = readFileSync(filepath, "utf-8");
39
+ return content.includes(`<!-- session:${sessionId} -->`);
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** Deduplicate notes against what's already in today's file */
46
+ function dedup(notes: RelationshipNote[], filepath: string): RelationshipNote[] {
47
+ if (!existsSync(filepath)) return notes;
48
+ try {
49
+ const existing = readFileSync(filepath, "utf-8").toLowerCase();
50
+ return notes.filter((n) => {
51
+ // Check if a substantially similar line already exists
52
+ const key = n.text.slice(0, 80).toLowerCase();
53
+ return !existing.includes(key);
54
+ });
55
+ } catch {
56
+ return notes;
57
+ }
58
+ }
59
+
60
+ /** Append notes to today's relationship file */
61
+ export function appendNotes(notes: RelationshipNote[], sessionId?: string): void {
62
+ if (notes.length === 0) return;
63
+
64
+ const filepath = dailyFilePath(new Date());
65
+ const today = new Date().toISOString().slice(0, 10);
66
+
67
+ // Deduplicate against existing content
68
+ const fresh = dedup(notes, filepath);
69
+ if (fresh.length === 0) return;
70
+
71
+ const lines: string[] = [];
72
+
73
+ if (!existsSync(filepath)) {
74
+ lines.push(`# Relationship Notes — ${today}`, "");
75
+ }
76
+
77
+ const timestamp = new Date().toTimeString().slice(0, 5);
78
+ lines.push(`## ${timestamp}`);
79
+ if (sessionId) lines.push(`<!-- session:${sessionId} -->`);
80
+
81
+ for (const note of fresh) {
82
+ if (note.type === "O" && note.confidence !== undefined) {
83
+ lines.push(`- O(c=${note.confidence}): ${note.text}`);
84
+ } else {
85
+ lines.push(`- ${note.type}: ${note.text}`);
86
+ }
87
+ }
88
+
89
+ lines.push("");
90
+
91
+ const existing = existsSync(filepath) ? readFileSync(filepath, "utf-8") : "";
92
+ writeFileSync(filepath, existing + lines.join("\n"), "utf-8");
93
+ }
94
+
95
+ /** Load notes from the last N days as a single string */
96
+ export function loadRecentNotes(days: number = 2): string {
97
+ const relDir = paths.relationship();
98
+ if (!existsSync(relDir)) return "";
99
+
100
+ const cutoff = new Date();
101
+ cutoff.setDate(cutoff.getDate() - days);
102
+
103
+ const sections: string[] = [];
104
+
105
+ for (const monthDir of readdirSync(relDir).sort().reverse()) {
106
+ const monthPath = resolve(relDir, monthDir);
107
+ if (!existsSync(monthPath)) continue;
108
+
109
+ let files: string[];
110
+ try {
111
+ files = readdirSync(monthPath)
112
+ .filter((f) => f.endsWith(".md"))
113
+ .sort()
114
+ .reverse();
115
+ } catch {
116
+ continue;
117
+ }
118
+
119
+ for (const file of files) {
120
+ const dateStr = file.replace(".md", "");
121
+ if (new Date(dateStr) < cutoff) continue;
122
+
123
+ try {
124
+ const content = readFileSync(resolve(monthPath, file), "utf-8").trim();
125
+ if (content) sections.push(content);
126
+ } catch {
127
+ // skip unreadable files
128
+ }
129
+ }
130
+
131
+ if (sections.length > 0) break; // only go back one month at most
132
+ }
133
+
134
+ return sections.join("\n\n---\n\n");
135
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Shared security definitions and validation logic.
3
+ * Used by SecurityValidator.ts (Claude Code) and the opencode plugin.
4
+ */
5
+
6
+ /** Dangerous command patterns — always blocked */
7
+ export const BLOCKED_COMMANDS: [RegExp, string][] = [
8
+ [/rm\s+-rf\s+[/~]/, "Recursive delete of root or home"],
9
+ [/mkfs\./, "Filesystem format"],
10
+ [/dd\s+if=.*of=\/dev\//, "Raw disk write"],
11
+ [/>\s*\/dev\/sd/, "Direct device write"],
12
+ [/chmod\s+-R\s+777\s+\//, "Recursive world-writable root"],
13
+ [/:\(\)\{\s*:\|:&\s*\};:/, "Fork bomb"],
14
+ [/curl.*\|\s*(?:ba)?sh/, "Pipe to shell"],
15
+ [/wget.*\|\s*(?:ba)?sh/, "Pipe to shell"],
16
+ ];
17
+
18
+ /** Hook-managed files — single source of truth */
19
+ export const HOOK_MANAGED_FILES = [
20
+ "CLAUDE.md",
21
+ "AGENTS.md",
22
+ "ratings.jsonl",
23
+ "sessions.json",
24
+ "captured-learnings.json",
25
+ "counts.json",
26
+ "session-names.json",
27
+ "debug.log",
28
+ "last-responses.json",
29
+ "signal-cache.json",
30
+ "pending-failure.json",
31
+ "token-usage.jsonl",
32
+ ];
33
+
34
+ /** Hook-managed directories — AI must not write to or delete from these */
35
+ export const HOOK_MANAGED_DIRS = [
36
+ "memory/signals",
37
+ "memory/learning/failures",
38
+ "memory/learning/session",
39
+ "memory/learning/synthesis",
40
+ "memory/relationship",
41
+ ];
42
+
43
+ /** Escape a string for use in a RegExp */
44
+ function escapeRegExp(s: string): string {
45
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
46
+ }
47
+
48
+ /** Paths that should never be written to */
49
+ export const PROTECTED_PATHS: RegExp[] = [
50
+ /^\/etc\//,
51
+ /^\/boot\//,
52
+ /^\/System\//,
53
+ /\.ssh\/(?!config)/,
54
+ /\.gnupg\//,
55
+ // Derived from HOOK_MANAGED_FILES
56
+ ...HOOK_MANAGED_FILES.map((name) => new RegExp(`[/\\\\]${escapeRegExp(name)}$`)),
57
+ ];
58
+
59
+ /** Patterns that warrant a warning (logged but not blocked) */
60
+ export const WARN_COMMANDS: RegExp[] = [
61
+ /git\s+push\s+.*--force/,
62
+ /git\s+reset\s+--hard/,
63
+ /drop\s+(?:table|database)/i,
64
+ /truncate\s+table/i,
65
+ ];
66
+
67
+ /** Read-only commands allowed to reference protected files */
68
+ const READ_ONLY_COMMANDS =
69
+ /^\s*(?:cat|head|tail|less|more|grep|rg|wc|diff|stat|file|ls|dir|git\s+(?:log|diff|blame|show|status)|bat)\b/;
70
+
71
+ /** Check a bash command against blocked patterns. Returns reason string or null. */
72
+ export function checkBashCommand(cmd: string): string | null {
73
+ for (const [pattern, reason] of BLOCKED_COMMANDS) {
74
+ if (pattern.test(cmd)) return reason;
75
+ }
76
+ // If command mentions a protected file, block unless it's read-only
77
+ for (const name of HOOK_MANAGED_FILES) {
78
+ if (cmd.includes(name)) {
79
+ // Check each piped segment — if any segment is not read-only, block
80
+ const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
81
+ const allReadOnly = segments
82
+ .filter((s) => s.includes(name))
83
+ .every((s) => READ_ONLY_COMMANDS.test(s));
84
+ if (!allReadOnly) {
85
+ return `${name} is managed automatically by hooks — do not edit directly`;
86
+ }
87
+ }
88
+ }
89
+ // If command mentions a hook-managed directory, block unless it's read-only
90
+ for (const dir of HOOK_MANAGED_DIRS) {
91
+ if (cmd.includes(dir)) {
92
+ const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
93
+ const allReadOnly = segments
94
+ .filter((s) => s.includes(dir))
95
+ .every((s) => READ_ONLY_COMMANDS.test(s));
96
+ if (!allReadOnly) {
97
+ return `${dir} is managed automatically by hooks — do not edit directly`;
98
+ }
99
+ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /** Check a file path against protected patterns. Returns a reason string or null. */
105
+ export function checkFilePath(filePath: string): string | null {
106
+ const normalized = filePath.replace(/\\/g, "/");
107
+ // Check hook-managed files first (more specific message)
108
+ const matchedFile = HOOK_MANAGED_FILES.find((name) => normalized.endsWith(`/${name}`));
109
+ if (matchedFile) {
110
+ return `${matchedFile} is managed automatically by hooks — do not edit directly`;
111
+ }
112
+ // Check hook-managed directories
113
+ const matchedDir = HOOK_MANAGED_DIRS.find((dir) => normalized.includes(`/${dir}/`));
114
+ if (matchedDir) {
115
+ return `${matchedDir}/ is managed automatically by hooks — do not edit directly`;
116
+ }
117
+ // Check system-protected paths
118
+ if (PROTECTED_PATHS.some((pattern) => pattern.test(filePath))) {
119
+ return `Protected path: ${filePath}`;
120
+ }
121
+ return null;
122
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Session naming utilities — 4-word headline per session, stored in session-names.json.
3
+ */
4
+
5
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { resolve } from "node:path";
7
+ import { paths } from "./paths";
8
+
9
+ export interface SessionNames {
10
+ [sessionId: string]: string;
11
+ }
12
+
13
+ // Noise words that produce garbage session names — extended from original PAI
14
+ const NOISE_WORDS = new Set([
15
+ // Articles, pronouns, prepositions
16
+ "a",
17
+ "an",
18
+ "the",
19
+ "and",
20
+ "or",
21
+ "but",
22
+ "in",
23
+ "on",
24
+ "at",
25
+ "to",
26
+ "for",
27
+ "of",
28
+ "with",
29
+ "from",
30
+ "by",
31
+ "as",
32
+ "is",
33
+ "it",
34
+ "its",
35
+ "this",
36
+ "that",
37
+ "i",
38
+ "you",
39
+ "we",
40
+ "my",
41
+ "me",
42
+ "your",
43
+ "our",
44
+ "they",
45
+ "them",
46
+ "those",
47
+ "these",
48
+ // Common verbs (too generic for names)
49
+ "can",
50
+ "do",
51
+ "not",
52
+ "be",
53
+ "are",
54
+ "was",
55
+ "were",
56
+ "just",
57
+ "so",
58
+ "if",
59
+ "how",
60
+ "what",
61
+ "when",
62
+ "where",
63
+ "which",
64
+ "who",
65
+ "have",
66
+ "has",
67
+ "had",
68
+ "will",
69
+ "would",
70
+ "could",
71
+ "should",
72
+ "been",
73
+ "being",
74
+ "having",
75
+ "getting",
76
+ "making",
77
+ // Filler words
78
+ "there",
79
+ "here",
80
+ "some",
81
+ "all",
82
+ "any",
83
+ "each",
84
+ "every",
85
+ "both",
86
+ "more",
87
+ "most",
88
+ "less",
89
+ "much",
90
+ "many",
91
+ "few",
92
+ "really",
93
+ "actually",
94
+ "basically",
95
+ "pretty",
96
+ "very",
97
+ "quite",
98
+ "super",
99
+ "totally",
100
+ "completely",
101
+ "okay",
102
+ "yeah",
103
+ "yes",
104
+ "sure",
105
+ "fine",
106
+ "good",
107
+ "bad",
108
+ "great",
109
+ "nice",
110
+ "hey",
111
+ "well",
112
+ "now",
113
+ "then",
114
+ "still",
115
+ "even",
116
+ "already",
117
+ "yet",
118
+ "ago",
119
+ // Generic task words (too vague)
120
+ "thing",
121
+ "things",
122
+ "something",
123
+ "nothing",
124
+ "anything",
125
+ "everything",
126
+ "stuff",
127
+ "way",
128
+ "kind",
129
+ "sort",
130
+ "type",
131
+ "part",
132
+ "whole",
133
+ "point",
134
+ // Common non-topic words
135
+ "need",
136
+ "want",
137
+ "please",
138
+ "help",
139
+ "work",
140
+ "working",
141
+ "doing",
142
+ "going",
143
+ "like",
144
+ "know",
145
+ "think",
146
+ "right",
147
+ "look",
148
+ "see",
149
+ "try",
150
+ "let",
151
+ "get",
152
+ "set",
153
+ "put",
154
+ "use",
155
+ "run",
156
+ "make",
157
+ "tell",
158
+ "show",
159
+ "give",
160
+ "keep",
161
+ "start",
162
+ "stop",
163
+ "move",
164
+ "turn",
165
+ "pull",
166
+ "push",
167
+ "open",
168
+ "close",
169
+ "guess",
170
+ "maybe",
171
+ "probably",
172
+ "mean",
173
+ "means",
174
+ "called",
175
+ "used",
176
+ "using",
177
+ // Tool/system artifacts
178
+ "output",
179
+ "file",
180
+ "result",
181
+ "tool",
182
+ "input",
183
+ "content",
184
+ "contents",
185
+ "invoke",
186
+ ]);
187
+
188
+ /** Strip system-injected artifacts from prompt before naming */
189
+ function sanitizePrompt(prompt: string): string {
190
+ return prompt
191
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/gi, " ")
192
+ .replace(/<task-notification>[\s\S]*?<\/task-notification>/gi, " ")
193
+ .replace(/<[^>]+>/g, " ")
194
+ .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, " ")
195
+ .replace(/\b[0-9a-f]{7,}\b/gi, " ")
196
+ .replace(/(?:\/[\w.-]+){2,}/g, " ")
197
+ .replace(/\s+/g, " ")
198
+ .trim();
199
+ }
200
+
201
+ /** Deterministic fallback: extract up to 4 meaningful keywords, Title Case */
202
+ export function extractFallbackName(prompt: string): string {
203
+ const sanitized = sanitizePrompt(prompt);
204
+ const words = sanitized
205
+ .replace(/[^a-zA-Z\s]/g, " ")
206
+ .split(/\s+/)
207
+ .filter((w) => w.length >= 3 && !NOISE_WORDS.has(w.toLowerCase()));
208
+
209
+ // Deduplicate while preserving order
210
+ const seen = new Set<string>();
211
+ const unique: string[] = [];
212
+ for (const w of words) {
213
+ const lower = w.toLowerCase();
214
+ if (!seen.has(lower)) {
215
+ seen.add(lower);
216
+ unique.push(w);
217
+ }
218
+ if (unique.length >= 4) break;
219
+ }
220
+
221
+ if (unique.length === 0) return "untitled session";
222
+
223
+ return unique
224
+ .slice(0, 4)
225
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
226
+ .join(" ");
227
+ }
228
+
229
+ function namesFilePath(): string {
230
+ return resolve(paths.state(), "session-names.json");
231
+ }
232
+
233
+ export function readSessionNames(): SessionNames {
234
+ const filepath = namesFilePath();
235
+ if (!existsSync(filepath)) return {};
236
+ try {
237
+ return JSON.parse(readFileSync(filepath, "utf-8")) as SessionNames;
238
+ } catch {
239
+ return {};
240
+ }
241
+ }
242
+
243
+ export function writeSessionName(sessionId: string, name: string): void {
244
+ const names = readSessionNames();
245
+ names[sessionId] = name;
246
+ writeFileSync(namesFilePath(), JSON.stringify(names, null, 2), "utf-8");
247
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Setup state management for PAL first-run wizard.
3
+ *
4
+ * State lives in memory/state/setup.json. Each step maps to a TELOS file.
5
+ * The AI is instructed to mark steps done after writing each file.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { ensureDir, palHome, paths } from "./paths";
11
+
12
+ export interface SetupStep {
13
+ done: boolean;
14
+ file: string;
15
+ question: string;
16
+ hint: string;
17
+ }
18
+
19
+ export interface SetupState {
20
+ version: number;
21
+ completed: boolean;
22
+ steps: Record<string, SetupStep>;
23
+ }
24
+
25
+ /** Ordered setup steps — defines the wizard flow */
26
+ const SETUP_STEPS: Record<string, Omit<SetupStep, "done">> = {
27
+ mission: {
28
+ file: "telos/MISSION.md",
29
+ question: "What's your name and what do you do?",
30
+ hint: "Write their name, role, and core purpose to telos/MISSION.md",
31
+ },
32
+ ai_name: {
33
+ file: "telos/IDENTITY.md",
34
+ question:
35
+ "What would you like to call your AI? (Pick a name — this is how I'll identify myself.)",
36
+ hint: "Write the chosen AI name and identity to telos/IDENTITY.md with fields: name, fullName (name — Personal AI), displayName (UPPERCASED)",
37
+ },
38
+ catchphrase: {
39
+ file: "telos/IDENTITY.md",
40
+ question:
41
+ 'What should your AI\'s startup catchphrase be? (e.g. "{name} here, ready to go" — {name} gets replaced with the AI name.)',
42
+ hint: "Append the catchphrase to telos/IDENTITY.md under a ## Catchphrase heading. Support {name} as a placeholder.",
43
+ },
44
+ goals: {
45
+ file: "telos/GOALS.md",
46
+ question: "What are your current goals? (short-term, medium-term, long-term)",
47
+ hint: "Write goals organized by timeframe to telos/GOALS.md",
48
+ },
49
+ projects: {
50
+ file: "telos/PROJECTS.md",
51
+ question: "What projects are you currently working on?",
52
+ hint: "Write to telos/PROJECTS.md using table format: | Project | Status | Priority | Notes |",
53
+ },
54
+ beliefs: {
55
+ file: "telos/BELIEFS.md",
56
+ question: "What principles or values guide your work?",
57
+ hint: "Write their values and principles to telos/BELIEFS.md",
58
+ },
59
+ challenges: {
60
+ file: "telos/CHALLENGES.md",
61
+ question: "What are your biggest current challenges?",
62
+ hint: "Write their challenges and obstacles to telos/CHALLENGES.md",
63
+ },
64
+ };
65
+
66
+ export const STEP_ORDER = Object.keys(SETUP_STEPS);
67
+
68
+ function setupPath(): string {
69
+ return resolve(ensureDir(paths.state()), "setup.json");
70
+ }
71
+
72
+ /** Check if a TELOS file has real content (not just template scaffolding) */
73
+ function hasRealContent(filePath: string): boolean {
74
+ if (!existsSync(filePath)) return false;
75
+ try {
76
+ const content = readFileSync(filePath, "utf-8").trim();
77
+ return content
78
+ .split("\n")
79
+ .some(
80
+ (l) =>
81
+ !l.startsWith("#") &&
82
+ !l.startsWith("<!--") &&
83
+ !l.startsWith("-->") &&
84
+ l.trim() &&
85
+ !/^\s*-\s*$/.test(l) &&
86
+ !/^\s*\|/.test(l)
87
+ );
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ /** Create initial setup state, auto-detecting already-populated TELOS files */
94
+ export function createInitialState(): SetupState {
95
+ const steps: Record<string, SetupStep> = {};
96
+ for (const [key, def] of Object.entries(SETUP_STEPS)) {
97
+ const populated = hasRealContent(resolve(palHome(), def.file));
98
+ steps[key] = { done: populated, ...def };
99
+ }
100
+ const allDone = Object.values(steps).every((s) => s.done);
101
+ return { version: 1, completed: allDone, steps };
102
+ }
103
+
104
+ /** Read setup state, or return null if no setup.json exists */
105
+ export function readSetupState(): SetupState | null {
106
+ const p = setupPath();
107
+ if (!existsSync(p)) return null;
108
+ try {
109
+ return JSON.parse(readFileSync(p, "utf-8"));
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** Write setup state to disk */
116
+ export function writeSetupState(state: SetupState): void {
117
+ writeFileSync(setupPath(), `${JSON.stringify(state, null, 2)}\n`);
118
+ }
119
+
120
+ /** Seed setup.json if it doesn't exist yet. Returns the state. */
121
+ export function ensureSetupState(): SetupState {
122
+ const existing = readSetupState();
123
+ if (existing) return existing;
124
+ const fresh = createInitialState();
125
+ writeSetupState(fresh);
126
+ return fresh;
127
+ }
128
+
129
+ /** Get the list of remaining (not done) step keys, in order */
130
+ export function remainingSteps(state: SetupState): string[] {
131
+ return STEP_ORDER.filter((k) => !state.steps[k]?.done);
132
+ }
133
+
134
+ /** Check if setup is fully completed */
135
+ export function isSetupComplete(state: SetupState): boolean {
136
+ return state.completed;
137
+ }
138
+
139
+ /**
140
+ * Build the system-prompt instructions for the current setup state.
141
+ * Returns null if setup is already complete.
142
+ */
143
+ export function buildSetupPrompt(state: SetupState): string | null {
144
+ if (state.completed) return null;
145
+
146
+ const remaining = remainingSteps(state);
147
+ if (remaining.length === 0) return null;
148
+
149
+ const completedSteps = STEP_ORDER.filter((k) => state.steps[k]?.done);
150
+ const totalSteps = STEP_ORDER.length;
151
+
152
+ const lines: string[] = [
153
+ "## IMPORTANT: PAL First-Run Setup Required",
154
+ "",
155
+ "TELOS files are empty — this user has not been set up yet.",
156
+ "You MUST start the setup process immediately, regardless of what the user says.",
157
+ "Greet them, explain that PAL needs to learn about them to personalize future sessions,",
158
+ "and ask the first remaining question below. Do NOT wait for the user to ask about setup.",
159
+ "",
160
+ ];
161
+
162
+ if (completedSteps.length > 0) {
163
+ lines.push(
164
+ `Setup in progress — ${completedSteps.length}/${totalSteps} steps complete. Continue from the next remaining step.`,
165
+ ""
166
+ );
167
+ }
168
+
169
+ lines.push("### Steps to complete (ask one at a time):", "");
170
+
171
+ for (const key of remaining) {
172
+ const step = state.steps[key];
173
+ lines.push(`- **${key}** — Ask: "${step.question}" → ${step.hint}`);
174
+ }
175
+
176
+ lines.push(
177
+ "",
178
+ "### After each answer:",
179
+ "1. Write the user's answer to the corresponding TELOS file.",
180
+ `2. Read \`memory/state/setup.json\`, set \`steps.<key>.done = true\`, and write it back.`,
181
+ "3. Ask the next remaining question.",
182
+ "",
183
+ `When all steps are done (or the user wants to skip), set \`completed: true\` in setup.json.`,
184
+ "",
185
+ "Keep it conversational and natural. If the user wants to skip a step, mark it done and move on."
186
+ );
187
+
188
+ return lines.join("\n");
189
+ }