youmd 0.3.3 → 0.4.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.
@@ -1 +1 @@
1
- {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AA+sBA,wBAAsB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAwXjD"}
1
+ {"version":3,"file":"chat.d.ts","sourceRoot":"","sources":["../../src/commands/chat.ts"],"names":[],"mappings":"AA25BA,wBAAsB,WAAW,IAAI,OAAO,CAAC,IAAI,CAAC,CAihBjD"}
@@ -42,12 +42,127 @@ const fs = __importStar(require("fs"));
42
42
  const path = __importStar(require("path"));
43
43
  const chalk_1 = __importDefault(require("chalk"));
44
44
  const config_1 = require("../lib/config");
45
+ const project_1 = require("../lib/project");
45
46
  const compiler_1 = require("../lib/compiler");
46
47
  const api_1 = require("../lib/api");
47
48
  const render_1 = require("../lib/render");
48
49
  const onboarding_1 = require("../lib/onboarding");
49
50
  // ─── URL Detection + Scraping (mirrors web useYouAgent) ──────────────
50
51
  const CONVEX_SITE_URL = "https://kindly-cassowary-600.convex.site";
52
+ const STREAM_URL = `${CONVEX_SITE_URL}/api/v1/chat/stream`;
53
+ // ─── Streaming LLM client ─────────────────────────────────────────────
54
+ async function streamLLM(_apiKey, messages, onToken) {
55
+ const res = await fetch(STREAM_URL, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({ messages }),
59
+ signal: AbortSignal.timeout(120000),
60
+ });
61
+ if (!res.ok) {
62
+ const body = await res.text();
63
+ throw new Error(`Stream error (${res.status}): ${body}`);
64
+ }
65
+ if (!res.body) {
66
+ throw new Error("No response body from stream endpoint");
67
+ }
68
+ const reader = res.body.getReader();
69
+ const decoder = new TextDecoder();
70
+ let fullText = "";
71
+ let buffer = "";
72
+ while (true) {
73
+ const { done, value } = await reader.read();
74
+ if (done)
75
+ break;
76
+ buffer += decoder.decode(value, { stream: true });
77
+ // Process complete SSE lines
78
+ const lines = buffer.split("\n");
79
+ // Keep the last potentially incomplete line in the buffer
80
+ buffer = lines.pop() || "";
81
+ for (const line of lines) {
82
+ const trimmed = line.trim();
83
+ if (!trimmed)
84
+ continue;
85
+ if (trimmed.startsWith("data: ")) {
86
+ const data = trimmed.slice(6);
87
+ if (data === "[DONE]") {
88
+ continue;
89
+ }
90
+ try {
91
+ const parsed = JSON.parse(data);
92
+ if (parsed.text) {
93
+ fullText += parsed.text;
94
+ onToken(parsed.text);
95
+ }
96
+ }
97
+ catch {
98
+ // Skip malformed JSON chunks
99
+ }
100
+ }
101
+ }
102
+ }
103
+ // Process any remaining buffer
104
+ if (buffer.trim()) {
105
+ const trimmed = buffer.trim();
106
+ if (trimmed.startsWith("data: ")) {
107
+ const data = trimmed.slice(6);
108
+ if (data !== "[DONE]") {
109
+ try {
110
+ const parsed = JSON.parse(data);
111
+ if (parsed.text) {
112
+ fullText += parsed.text;
113
+ onToken(parsed.text);
114
+ }
115
+ }
116
+ catch {
117
+ // Skip
118
+ }
119
+ }
120
+ }
121
+ }
122
+ return fullText;
123
+ }
124
+ /**
125
+ * Call LLM with streaming, falling back to blocking callLLM on failure.
126
+ * Returns the full response text.
127
+ */
128
+ async function callLLMWithStreaming(apiKey, messages, spinnerLabel) {
129
+ const thinkSpinner = new render_1.BrailleSpinner(spinnerLabel);
130
+ thinkSpinner.start();
131
+ try {
132
+ let firstToken = true;
133
+ const response = await streamLLM(apiKey, messages, (token) => {
134
+ if (firstToken) {
135
+ // Clear the spinner line before writing streamed text
136
+ thinkSpinner.stop();
137
+ process.stdout.write(" ");
138
+ firstToken = false;
139
+ }
140
+ process.stdout.write(token);
141
+ });
142
+ if (!firstToken) {
143
+ // We streamed something -- add trailing newline
144
+ process.stdout.write("\n");
145
+ }
146
+ else {
147
+ // No tokens received -- clear spinner
148
+ thinkSpinner.stop();
149
+ }
150
+ return response;
151
+ }
152
+ catch {
153
+ // Streaming failed -- fall back to blocking call
154
+ thinkSpinner.update("streaming unavailable, waiting for response");
155
+ try {
156
+ const response = await (0, onboarding_1.callLLM)(apiKey, messages);
157
+ thinkSpinner.stop();
158
+ return response;
159
+ }
160
+ catch (err) {
161
+ thinkSpinner.fail(err instanceof Error ? err.message : "failed");
162
+ throw err;
163
+ }
164
+ }
165
+ }
51
166
  function detectSourcesInMessage(text) {
52
167
  const sources = [];
53
168
  const seen = new Set();
@@ -212,6 +327,51 @@ function parsePrivateUpdates(text) {
212
327
  return updates;
213
328
  }
214
329
  const scrapedSources = new Set();
330
+ // ─── Image/File handling ──────────────────────────────────────────────
331
+ function detectFilePath(input) {
332
+ const trimmed = input.trim();
333
+ // Detect dragged file paths (terminals add quotes or escape spaces)
334
+ // Strip surrounding quotes
335
+ const unquoted = trimmed.replace(/^['"]|['"]$/g, "");
336
+ // Check if it looks like a file path
337
+ if ((unquoted.startsWith("/") || unquoted.startsWith("~") || unquoted.startsWith("./")) &&
338
+ fs.existsSync(unquoted)) {
339
+ return unquoted;
340
+ }
341
+ return null;
342
+ }
343
+ function isImageFile(filePath) {
344
+ const ext = path.extname(filePath).toLowerCase();
345
+ return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"].includes(ext);
346
+ }
347
+ function fileToBase64DataUrl(filePath) {
348
+ try {
349
+ const buffer = fs.readFileSync(filePath);
350
+ const ext = path.extname(filePath).toLowerCase();
351
+ const mimeMap = {
352
+ ".png": "image/png",
353
+ ".jpg": "image/jpeg",
354
+ ".jpeg": "image/jpeg",
355
+ ".gif": "image/gif",
356
+ ".webp": "image/webp",
357
+ ".bmp": "image/bmp",
358
+ ".svg": "image/svg+xml",
359
+ };
360
+ const mime = mimeMap[ext] || "application/octet-stream";
361
+ return `data:${mime};base64,${buffer.toString("base64")}`;
362
+ }
363
+ catch {
364
+ return null;
365
+ }
366
+ }
367
+ function readTextFile(filePath) {
368
+ try {
369
+ return fs.readFileSync(filePath, "utf-8");
370
+ }
371
+ catch {
372
+ return null;
373
+ }
374
+ }
215
375
  // ─── Constants ────────────────────────────────────────────────────────
216
376
  const CHAT_SYSTEM_PROMPT = `you are the you.md agent — the first AI that truly knows people. you help humans build and maintain their identity file for the agent internet. not a chatbot. not an assistant. an identity specialist with a personality.
217
377
 
@@ -269,7 +429,16 @@ your job:
269
429
  5. be proactive: "looks like your projects section could use an update — want to add that?"
270
430
  6. when they share something sensitive, ask: "want me to keep that private or add it to your public profile?"
271
431
 
272
- rules: each section starts with YAML frontmatter. real markdown, not placeholders. output FULL section content each time. be substantive — write from what you actually know.`;
432
+ rules: each section starts with YAML frontmatter. real markdown, not placeholders. output FULL section content each time. be substantive — write from what you actually know.
433
+
434
+ --- project context updates ---
435
+
436
+ if the user is working in a project (you'll see a [PROJECT CONTEXT] block), you can update project files. when you learn something about the project — a decision made, a task completed, a feature shipped, a new requirement — output:
437
+ \`\`\`json
438
+ {"project_updates": [{"file": "context/todo.md", "content": "updated content..."}]}
439
+ \`\`\`
440
+ allowed files: context/todo.md, context/features.md, context/changelog.md, context/decisions.md, context/prd.md, agent/instructions.md, agent/memory.json, private/notes.md
441
+ only output project_updates when something actually changed. the system will write these files and show a notice to the user.`;
273
442
  const SLASH_COMMANDS = {
274
443
  "/status": "show bundle status",
275
444
  "/preview": "show profile preview",
@@ -280,6 +449,7 @@ const SLASH_COMMANDS = {
280
449
  "/memory": "show memory summary + stats",
281
450
  "/recall": "show recent memories (or /recall query)",
282
451
  "/private": "show private context (notes, links, projects)",
452
+ "/image <path>": "attach an image or file",
283
453
  "/rebuild": "recompile the bundle",
284
454
  "/help": "show available commands",
285
455
  "/done": "exit chat",
@@ -625,12 +795,74 @@ async function chatCommand() {
625
795
  const bundleDir = (0, config_1.getLocalBundleDir)();
626
796
  const apiKey = (0, onboarding_1.getOpenRouterKey)();
627
797
  const rl = createRL();
798
+ // Detect project context (legacy detection from config.ts)
799
+ const projectCtx = (0, config_1.detectProjectContext)();
800
+ let projectContextBlock = "";
801
+ let activeProjectDir = null;
802
+ if (projectCtx) {
803
+ console.log("");
804
+ console.log(" " + chalk_1.default.hex("#C46A3A")("project:") + " " + chalk_1.default.white(projectCtx.name) +
805
+ chalk_1.default.dim(` (${projectCtx.root})`));
806
+ // Try the new file-system project context first
807
+ const projectsRoot = (0, project_1.findProjectsRoot)();
808
+ if (projectsRoot) {
809
+ const detected = (0, project_1.detectCurrentProject)(projectsRoot);
810
+ if (detected) {
811
+ activeProjectDir = (0, project_1.getProjectDir)(projectsRoot, detected);
812
+ const injection = (0, project_1.buildProjectContextInjection)(activeProjectDir);
813
+ if (injection) {
814
+ projectContextBlock = `\n\n--- project context ---\n${injection}`;
815
+ }
816
+ }
817
+ }
818
+ // Fallback to legacy project context if new system didn't produce anything
819
+ if (!projectContextBlock) {
820
+ const projectNotes = (0, config_1.readProjectPrivateNotes)(projectCtx.name);
821
+ const parts = [];
822
+ parts.push(`the user is currently working in project: ${projectCtx.name} at ${projectCtx.root}`);
823
+ if (projectCtx.youmdProject?.description) {
824
+ parts.push(`project description: ${projectCtx.youmdProject.description}`);
825
+ }
826
+ if (projectNotes) {
827
+ parts.push(`project-specific private notes:\n${projectNotes}`);
828
+ }
829
+ projectContextBlock = `\n\n--- project context ---\n${parts.join("\n")}`;
830
+ }
831
+ }
628
832
  console.log("");
629
833
  console.log(" " + chalk_1.default.bold("you.md chat"));
630
834
  console.log(chalk_1.default.dim(" talk to update your profile. /help for commands."));
631
835
  console.log("");
632
836
  // Load current profile as context
633
837
  const currentBundle = loadCurrentBundle(bundleDir);
838
+ // Load agent directives from you.json if available
839
+ let directivesContext = "";
840
+ const youJsonPath = path.join(bundleDir, "you.json");
841
+ if (fs.existsSync(youJsonPath)) {
842
+ try {
843
+ const youJson = JSON.parse(fs.readFileSync(youJsonPath, "utf-8"));
844
+ const directives = youJson.agent_directives;
845
+ if (directives) {
846
+ const parts = [];
847
+ if (directives.communication_style)
848
+ parts.push(`communication style: ${directives.communication_style}`);
849
+ if (directives.default_stack)
850
+ parts.push(`default stack: ${directives.default_stack}`);
851
+ if (directives.current_goal)
852
+ parts.push(`current goal: ${directives.current_goal}`);
853
+ if (directives.decision_framework)
854
+ parts.push(`decision framework: ${directives.decision_framework}`);
855
+ if (directives.negative_prompts && directives.negative_prompts.length > 0)
856
+ parts.push(`never do: ${directives.negative_prompts.join("; ")}`);
857
+ if (parts.length > 0) {
858
+ directivesContext = `\n\n--- agent directives (follow these when interacting with me) ---\n${parts.join("\n")}`;
859
+ }
860
+ }
861
+ }
862
+ catch {
863
+ // non-fatal — skip directives if you.json is malformed
864
+ }
865
+ }
634
866
  // Extract profile details for a personalized greeting prompt
635
867
  const profileHint = extractProfileHint(bundleDir);
636
868
  let greetingInstruction = "greet me briefly and ask what i'd like to update or work on. keep it short.";
@@ -641,25 +873,21 @@ async function chatCommand() {
641
873
  { role: "system", content: CHAT_SYSTEM_PROMPT },
642
874
  {
643
875
  role: "user",
644
- content: `here is my current identity bundle:\n\n${currentBundle}\n\n${greetingInstruction}`,
876
+ content: `here is my current identity bundle:\n\n${currentBundle}${directivesContext}${projectContextBlock}\n\n${greetingInstruction}`,
645
877
  },
646
878
  ];
647
879
  // Initial greeting from agent
648
- const spinner = new onboarding_1.Spinner((0, onboarding_1.randomThinking)());
649
- spinner.start();
650
880
  let response;
651
881
  try {
652
- response = await (0, onboarding_1.callLLM)(apiKey, messages);
882
+ response = await callLLMWithStreaming(apiKey, messages, (0, onboarding_1.randomThinking)());
653
883
  }
654
884
  catch (err) {
655
- spinner.stop();
656
885
  console.log(chalk_1.default.red(` failed to connect: ${err instanceof Error ? err.message : String(err)}`));
657
886
  console.log(chalk_1.default.dim(" chat requires the AI service. try again later."));
658
887
  console.log("");
659
888
  rl.close();
660
889
  return;
661
890
  }
662
- spinner.stop();
663
891
  messages.push({ role: "assistant", content: response });
664
892
  const initial = (0, onboarding_1.parseUpdatesFromResponse)(response);
665
893
  // Write any updates (unlikely on greeting, but handle it)
@@ -670,6 +898,9 @@ async function chatCommand() {
670
898
  console.log(chalk_1.default.cyan(` [updated: ${initial.updates.map((u) => (0, onboarding_1.sectionLabel)(u.section)).join(", ")}]`));
671
899
  console.log("");
672
900
  }
901
+ // Only print via rich renderer if we didn't stream (streaming already wrote output)
902
+ // But we still need to display parsed output for non-streamed fallback
903
+ // Since streaming writes raw text, print formatted version for updates parsing
673
904
  printAgentMessage(initial.display);
674
905
  // ── Conversation loop ──────────────────────────────────────────────
675
906
  while (true) {
@@ -803,20 +1034,16 @@ async function chatCommand() {
803
1034
  if (!researchOk)
804
1035
  continue;
805
1036
  // After research, get an LLM response with the injected context
806
- const researchSpinner = new onboarding_1.Spinner((0, onboarding_1.randomThinking)());
807
- researchSpinner.start();
808
1037
  try {
809
- response = await (0, onboarding_1.callLLM)(apiKey, messages);
1038
+ response = await callLLMWithStreaming(apiKey, messages, (0, onboarding_1.randomThinking)());
810
1039
  }
811
1040
  catch (err) {
812
- researchSpinner.stop();
813
1041
  console.log(chalk_1.default.red(` AI error: ${err instanceof Error ? err.message : String(err)}`));
814
1042
  console.log(chalk_1.default.dim(" try again."));
815
1043
  console.log("");
816
1044
  messages.pop();
817
1045
  continue;
818
1046
  }
819
- researchSpinner.stop();
820
1047
  messages.push({ role: "assistant", content: response });
821
1048
  const researchParsed = (0, onboarding_1.parseUpdatesFromResponse)(response);
822
1049
  if (researchParsed.updates.length > 0) {
@@ -833,6 +1060,93 @@ async function chatCommand() {
833
1060
  handleRebuild(bundleDir);
834
1061
  continue;
835
1062
  }
1063
+ // ── Handle /image command ──
1064
+ if (lower.startsWith("/image ")) {
1065
+ const imgPath = userInput.slice(7).trim().replace(/^['"]|['"]$/g, "");
1066
+ if (!fs.existsSync(imgPath)) {
1067
+ console.log(chalk_1.default.hex("#C46A3A")(` file not found: ${imgPath}`));
1068
+ console.log("");
1069
+ continue;
1070
+ }
1071
+ if (isImageFile(imgPath)) {
1072
+ const dataUrl = fileToBase64DataUrl(imgPath);
1073
+ if (dataUrl) {
1074
+ console.log(chalk_1.default.green(` ✓`) + chalk_1.default.dim(` attached image: ${path.basename(imgPath)}`));
1075
+ messages.push({
1076
+ role: "user",
1077
+ content: `[USER ATTACHED IMAGE: ${path.basename(imgPath)}]\nthe user attached an image file. describe or use it as needed.\n![${path.basename(imgPath)}](${dataUrl})`,
1078
+ });
1079
+ }
1080
+ }
1081
+ else {
1082
+ const text = readTextFile(imgPath);
1083
+ if (text) {
1084
+ console.log(chalk_1.default.green(` ✓`) + chalk_1.default.dim(` attached file: ${path.basename(imgPath)} (${text.length} chars)`));
1085
+ messages.push({
1086
+ role: "user",
1087
+ content: `[USER ATTACHED FILE: ${path.basename(imgPath)}]\n\`\`\`\n${text.slice(0, 10000)}\n\`\`\``,
1088
+ });
1089
+ }
1090
+ }
1091
+ try {
1092
+ response = await callLLMWithStreaming(apiKey, messages, (0, onboarding_1.randomThinking)());
1093
+ }
1094
+ catch (err) {
1095
+ console.log(chalk_1.default.red(` ${err instanceof Error ? err.message : "failed"}`));
1096
+ messages.pop();
1097
+ continue;
1098
+ }
1099
+ messages.push({ role: "assistant", content: response });
1100
+ printAgentMessage((0, onboarding_1.parseUpdatesFromResponse)(response).display);
1101
+ continue;
1102
+ }
1103
+ // ── Detect dragged/pasted file paths ──
1104
+ const detectedFile = detectFilePath(userInput);
1105
+ if (detectedFile) {
1106
+ if (isImageFile(detectedFile)) {
1107
+ const dataUrl = fileToBase64DataUrl(detectedFile);
1108
+ if (dataUrl) {
1109
+ console.log(chalk_1.default.green(` ✓`) + chalk_1.default.dim(` detected image: ${path.basename(detectedFile)}`));
1110
+ messages.push({
1111
+ role: "user",
1112
+ content: `[USER DROPPED IMAGE: ${path.basename(detectedFile)}]\nthe user dropped an image file into the chat.\n![${path.basename(detectedFile)}](${dataUrl})`,
1113
+ });
1114
+ try {
1115
+ response = await callLLMWithStreaming(apiKey, messages, (0, onboarding_1.randomThinking)());
1116
+ }
1117
+ catch (err) {
1118
+ console.log(chalk_1.default.red(` ${err instanceof Error ? err.message : "failed"}`));
1119
+ messages.pop();
1120
+ continue;
1121
+ }
1122
+ messages.push({ role: "assistant", content: response });
1123
+ printAgentMessage((0, onboarding_1.parseUpdatesFromResponse)(response).display);
1124
+ continue;
1125
+ }
1126
+ }
1127
+ else {
1128
+ // Text file — inject content
1129
+ const text = readTextFile(detectedFile);
1130
+ if (text) {
1131
+ console.log(chalk_1.default.green(` ✓`) + chalk_1.default.dim(` detected file: ${path.basename(detectedFile)} (${text.length} chars)`));
1132
+ messages.push({
1133
+ role: "user",
1134
+ content: `[USER DROPPED FILE: ${path.basename(detectedFile)}]\n\`\`\`\n${text.slice(0, 10000)}\n\`\`\`\n\nreview this file and suggest how it relates to my identity or profile.`,
1135
+ });
1136
+ try {
1137
+ response = await callLLMWithStreaming(apiKey, messages, (0, onboarding_1.randomThinking)());
1138
+ }
1139
+ catch (err) {
1140
+ console.log(chalk_1.default.red(` ${err instanceof Error ? err.message : "failed"}`));
1141
+ messages.pop();
1142
+ continue;
1143
+ }
1144
+ messages.push({ role: "assistant", content: response });
1145
+ printAgentMessage((0, onboarding_1.parseUpdatesFromResponse)(response).display);
1146
+ continue;
1147
+ }
1148
+ }
1149
+ }
836
1150
  // ── Auto-detect URLs and scrape before sending to LLM ──
837
1151
  const detectedSources = detectSourcesInMessage(userInput);
838
1152
  const newSources = detectedSources.filter((s) => !scrapedSources.has(`${s.platform}:${s.username || s.url}`));
@@ -863,13 +1177,11 @@ async function chatCommand() {
863
1177
  });
864
1178
  }
865
1179
  }
866
- const thinkSpinner = new render_1.BrailleSpinner((0, onboarding_1.randomThinking)());
867
- thinkSpinner.start();
868
1180
  try {
869
- response = await (0, onboarding_1.callLLM)(apiKey, messages);
1181
+ response = await callLLMWithStreaming(apiKey, messages, (0, onboarding_1.randomThinking)());
870
1182
  }
871
1183
  catch (err) {
872
- thinkSpinner.fail(err instanceof Error ? err.message : "failed");
1184
+ console.log(chalk_1.default.red(` ${err instanceof Error ? err.message : "failed"}`));
873
1185
  console.log(chalk_1.default.dim(" try again."));
874
1186
  console.log("");
875
1187
  messages.pop();
@@ -877,7 +1189,6 @@ async function chatCommand() {
877
1189
  messages.pop(); // remove scrape context too
878
1190
  continue;
879
1191
  }
880
- thinkSpinner.stop();
881
1192
  messages.push({ role: "assistant", content: response });
882
1193
  const parsed = (0, onboarding_1.parseUpdatesFromResponse)(response);
883
1194
  // Write section updates
@@ -923,6 +1234,21 @@ async function chatCommand() {
923
1234
  }
924
1235
  }
925
1236
  }
1237
+ // Handle project context updates
1238
+ if (activeProjectDir) {
1239
+ const projUpdates = (0, project_1.parseProjectUpdates)(response);
1240
+ if (projUpdates.length > 0) {
1241
+ for (const pu of projUpdates) {
1242
+ try {
1243
+ (0, project_1.updateProjectFile)(activeProjectDir, pu.file, pu.content);
1244
+ console.log(chalk_1.default.hex("#C46A3A")(` [updated project context: ${pu.file}]`));
1245
+ }
1246
+ catch {
1247
+ // non-fatal
1248
+ }
1249
+ }
1250
+ }
1251
+ }
926
1252
  printAgentMessage(parsed.display);
927
1253
  }
928
1254
  rl.close();