1// jobs/rawIdeaAutoPlace.js
2// Periodically picks up the latest pending text raw idea for each premium/god user
3// and fires the raw-idea orchestrator as if they had clicked the Auto-place button.
4
5import log from "../../seed/log.js";
6import User from "../../seed/models/user.js";
7import RawIdea from "./model.js";
8import Chat from "../../seed/models/chat.js";
9import { orchestrateRawIdeaPlacement } from "./pipeline.js";
10import { isUserOnline } from "../../seed/ws/websocket.js";
11import { userHasLlm } from "../../seed/llm/conversation.js";
12
13// ─────────────────────────────────────────────────────────────────────────
14// CONFIG
15// ─────────────────────────────────────────────────────────────────────────
16
17const ELIGIBLE_PLANS = ["standard", "premium"];
18
19// ─────────────────────────────────────────────────────────────────────────
20// STATE
21// ─────────────────────────────────────────────────────────────────────────
22
23let jobTimer = null;
24
25// ─────────────────────────────────────────────────────────────────────────
26// SINGLE-USER HANDLER
27// ─────────────────────────────────────────────────────────────────────────
28
29async function processUser(user) {
30 const userId = user._id.toString();
31
32 // Skip if user is currently online — they can trigger it themselves
33 if (isUserOnline(userId)) return;
34
35 // Skip if user has no LLM connection
36 if (!await userHasLlm(userId)) return;
37
38 // Mirror the button: skip if another idea is already being orchestrated
39 const alreadyProcessing = await RawIdea.findOne({
40 userId,
41 status: "processing",
42 }).lean();
43 if (alreadyProcessing) return;
44
45 // Find the latest pending text raw idea (same legacy-compat $or as getRawIdeas)
46 const rawIdea = await RawIdea.findOne({
47 userId,
48 contentType: "text",
49 $or: [
50 { status: "pending" },
51 { status: null },
52 { status: { $exists: false } },
53 ],
54 })
55 .sort({ createdAt: -1 })
56 .lean();
57
58 if (!rawIdea) return;
59
60 log.verbose("Raw Ideas",
61 `⏰ Auto-placing raw idea ${rawIdea._id} for user ${user.username}`,
62 );
63
64 // Fire-and-forget — same pattern as the HTTP route
65 orchestrateRawIdeaPlacement({
66 rawIdeaId: rawIdea._id.toString(),
67 userId,
68 username: user.username,
69 source: "background",
70 }).catch((err) =>
71 log.error("Raw Ideas",
72 `❌ Auto-place orchestration failed for user ${userId}:`,
73 err.message,
74 ),
75 );
76}
77
78// ─────────────────────────────────────────────────────────────────────────
79// MAIN RUN
80// ─────────────────────────────────────────────────────────────────────────
81
82export async function runRawIdeaAutoPlace() {
83 log.verbose("Raw Ideas", "⏰ Raw idea auto-place job running…");
84 try {
85 const users = await User.find({
86 $or: [
87 { "metadata.tiers.plan": { $in: ELIGIBLE_PLANS } },
88 { isAdmin: true },
89 ],
90 "metadata.rawIdeas.autoPlace": { $ne: false },
91 })
92 .select("_id username isAdmin metadata")
93 .lean();
94
95 if (users.length === 0) {
96 log.verbose("Raw Ideas", "⏰ No eligible users — skipping.");
97 return;
98 }
99
100 log.verbose("Raw Ideas", `⏰ ${users.length} eligible user(s) to check.`);
101
102 // Sequential so we don't fire 100 orchestrations simultaneously
103 for (const user of users) {
104 await processUser(user).catch((err) =>
105 log.error("Raw Ideas",
106 `⚠️ processUser error for ${user._id}:`,
107 err.message,
108 ),
109 );
110 }
111 } catch (err) {
112 log.error("Raw Ideas", " Raw idea auto-place job error:", err.message);
113 }
114}
115
116// ─────────────────────────────────────────────────────────────────────────
117// LIFECYCLE
118// ─────────────────────────────────────────────────────────────────────────
119
120/**
121 * Start the recurring job.
122 * @param {object} [opts]
123 * @param {number} [opts.intervalMs=900000] 15 min by default
124 */
125export async function startRawIdeaAutoPlaceJob({ intervalMs = 15 * 60 * 1000 } = {}) {
126 if (jobTimer) {
127 clearInterval(jobTimer);
128 }
129
130 // Reset any ideas left in "processing" from a previous server run
131 try {
132 const { modifiedCount } = await RawIdea.updateMany(
133 { status: "processing" },
134 { $set: { status: "pending" } },
135 );
136 if (modifiedCount > 0) {
137 log.verbose("Raw Ideas", `⏰ Reset ${modifiedCount} stale processing raw idea(s) → pending`);
138 }
139 } catch (err) {
140 log.error("Raw Ideas", " Failed to reset stale processing raw ideas:", err.message);
141 }
142
143 // Finalize any AI chats left without an endMessage from a previous server run
144 try {
145 const { modifiedCount } = await Chat.updateMany(
146 { "endMessage.time": null },
147 {
148 $set: {
149 "endMessage.time": new Date(),
150 "endMessage.stopped": true,
151 "endMessage.content": "Server restarted before completion",
152 },
153 },
154 );
155 if (modifiedCount > 0) {
156 log.verbose("Raw Ideas", `⏰ Finalized ${modifiedCount} stale pending AI chat(s)`);
157 }
158 } catch (err) {
159 log.error("Raw Ideas", " Failed to finalize stale AI chats:", err.message);
160 }
161
162 log.info("Raw Ideas", `⏰ Raw idea auto-place job started (interval: ${intervalMs / 1000}s)`,
163 );
164
165 // Run once immediately, then on every interval
166 jobTimer = setInterval(runRawIdeaAutoPlace, intervalMs);
167
168 // Return handle in case caller wants to store it
169 return jobTimer;
170}
171
172export function stopRawIdeaAutoPlaceJob() {
173 if (jobTimer) {
174 clearInterval(jobTimer);
175 jobTimer = null;
176 log.info("Raw Ideas", "⏹ Raw idea auto-place job stopped");
177 }
178}
179
1import path from "path";
2import fs from "fs";
3import RawIdea from "./model.js";
4import { DELETED } from "../../seed/protocol.js";
5import { createNote } from "../../seed/tree/notes.js";
6import { getUserMeta, setUserMeta, incUserMeta } from "../../seed/tree/userMetadata.js";
7import { getExtension } from "../loader.js";
8
9// Services wired from init() via setServices()
10let Node = null;
11let Note = null;
12let User = null;
13let logContribution = async () => {};
14let useEnergy = async () => ({ energyUsed: 0 });
15
16export function setServices({ models, contributions }) {
17 Node = models.Node;
18 Note = models.Note;
19 User = models.User;
20 if (contributions?.logContribution) logContribution = contributions.logContribution;
21}
22export function setEnergyService(energy) { useEnergy = energy.useEnergy; }
23
24import { fileURLToPath } from "url";
25
26const __filename = fileURLToPath(import.meta.url);
27const __dirname = path.dirname(__filename);
28const uploadsFolder = path.join(__dirname, "../../uploads");
29
30if (!fs.existsSync(uploadsFolder)) {
31 fs.mkdirSync(uploadsFolder);
32}
33
34async function extractTaggedUsersAndRewrite(content) {
35 const mentionRegex = /@([\w-]+)/g;
36 const matches = [...content.matchAll(mentionRegex)];
37
38 if (matches.length === 0) {
39 return { tagged: [], rewrittenContent: content };
40 }
41
42 const identifiers = matches.map((m) => m[1]);
43
44 const users = await User.find({
45 $or: [{ username: { $in: identifiers } }, { _id: { $in: identifiers } }],
46 });
47
48 const idToUser = {};
49 users.forEach((u) => {
50 idToUser[u._id] = u;
51 idToUser[u.username] = u;
52 });
53
54 const uniqueTagged = [...new Set(users.map((u) => u._id))];
55
56 const rewrittenContent = content.replace(mentionRegex, (full, ident) => {
57 const user = idToUser[ident];
58 if (!user) return full;
59 return "@" + user.username;
60 });
61
62 return { tagged: uniqueTagged, rewrittenContent };
63}
64
65const NOTE_TEXT_MAX_CHARS = 5000;
66
67export async function assertNoteTextWithinLimit(content, userId) {
68 if (!content) return;
69
70 if (userId) {
71 const user = await User.findById(userId).select("isAdmin").lean();
72 if (user?.isAdmin) return;
73 }
74
75 if (content.length > NOTE_TEXT_MAX_CHARS) {
76 throw new Error(
77 `Note exceeds maximum length of ${NOTE_TEXT_MAX_CHARS} characters`,
78 );
79 }
80}
81
82async function createRawIdea({
83 contentType,
84 content,
85 userId,
86 file,
87 wasAi = false,
88}) {
89 if (!contentType || !["file", "text"].includes(contentType)) {
90 throw new Error("Invalid content type");
91 }
92
93 if (!userId) {
94 throw new Error("Missing required field: userId");
95 }
96
97 let finalContent = content;
98 let taggedUserIds = [];
99
100 // ── FILE ───────────────────────────────────────
101 if (contentType === "file") {
102 if (!file) throw new Error("File is required for file content type");
103 finalContent = file.filename;
104 }
105
106 // ── TEXT ───────────────────────────────────────
107 if (contentType === "text") {
108 if (!content || typeof content !== "string") {
109 throw new Error("Content is required for text content type");
110 }
111
112 const { tagged, rewrittenContent } =
113 await extractTaggedUsersAndRewrite(content);
114
115 taggedUserIds = tagged;
116 finalContent = rewrittenContent;
117 await assertNoteTextWithinLimit(rewrittenContent, userId);
118 }
119
120 // ── ENERGY ─────────────────────────────────────
121 let payload;
122 if (contentType === "file") {
123 payload = { type: "file", sizeMB: Math.ceil(file.size / (1024 * 1024)) };
124 } else {
125 payload = content.length;
126 }
127
128 const { energyUsed } = await useEnergy({
129 userId,
130 action: "rawIdea",
131 payload,
132 file,
133 });
134
135 // ── SAVE ───────────────────────────────────────
136 const rawIdea = new RawIdea({
137 contentType,
138 content: finalContent,
139 userId,
140 tagged: taggedUserIds,
141 });
142
143 await rawIdea.save();
144
145 // ── STORAGE ────────────────────────────────────
146 const sizeKB = contentType === "file" && file?.size
147 ? Math.ceil(file.size / 1024)
148 : Math.ceil(Buffer.byteLength(finalContent || "", "utf8") / 1024);
149 if (sizeKB > 0) {
150 await incUserMeta(userId, "storage", "usageKB", sizeKB);
151 }
152
153 // ── LOG ────────────────────────────────────────
154 await logContribution({
155 userId,
156 nodeId: DELETED,
157 wasAi,
158 action: "rawIdea",
159 nodeVersion: "0",
160 rawIdeaAction: {
161 action: "add",
162 rawIdeaId: rawIdea._id.toString(),
163 },
164 energyUsed,
165 });
166
167 return {
168 message: "Raw idea captured",
169 rawIdea,
170 energyUsed,
171 };
172}
173
174async function convertRawIdeaToNote({
175 rawIdeaId,
176 userId,
177 nodeId,
178 wasAi = false,
179 chatId = null,
180 sessionId = null,
181}) {
182 if (!rawIdeaId || !userId || !nodeId) {
183 throw new Error("Missing or invalid required fields");
184 }
185
186 // 1️⃣ Load raw idea
187 const rawIdea = await RawIdea.findById(rawIdeaId);
188 if (!rawIdea) {
189 throw new Error("Raw idea not found");
190 }
191 if (rawIdea.status === "deleted" || rawIdea.status === "succeeded") {
192 throw new Error("Raw idea already placed or deleted");
193 }
194
195 if (rawIdea.userId.toString() !== userId) {
196 throw new Error("You do not own this raw idea");
197 }
198
199 // 2️⃣ Create note via core (handles hooks, energy, tags, storage, contribution)
200 const result = await createNote({
201 contentType: rawIdea.contentType,
202 content: rawIdea.content,
203 userId,
204 nodeId,
205 file: rawIdea.contentType === "file" ? { filename: rawIdea.content, size: 0 } : null,
206 wasAi,
207 chatId,
208 sessionId,
209 });
210
211 if (result.error) throw new Error(result.error);
212
213 // 3️⃣ Log raw idea placement contribution
214 await logContribution({
215 userId,
216 nodeId,
217 wasAi,
218 chatId,
219 sessionId,
220 action: "rawIdea",
221 nodeVersion: 0,
222 rawIdeaAction: {
223 action: "placed",
224 rawIdeaId: rawIdeaId.toString(),
225 targetNodeId: nodeId,
226 noteId: result.Note._id.toString(),
227 },
228 });
229
230 // 4️⃣ Mark raw idea as done
231 rawIdea.status = "deleted";
232 await rawIdea.save();
233
234 return {
235 message: "Raw idea converted to note",
236 note: result.Note,
237 };
238}
239
240async function deleteRawIdeaAndFile({ rawIdeaId, userId, wasAi = false }) {
241 const rawIdea = await RawIdea.findById(rawIdeaId);
242 if (!rawIdea) {
243 throw new Error("Raw idea not found");
244 }
245
246 // ownership check
247 if (rawIdea.userId.toString() !== userId) {
248 throw new Error("You do not own this raw idea");
249 }
250 let energyUsed = null;
251
252 if (rawIdea.contentType === "text") {
253 const energyResult = await useEnergy({
254 userId,
255 action: "removeNote",
256 });
257 energyUsed = energyResult.energyUsed;
258 }
259
260 let fileDeleted = false;
261
262 // --- FILE CLEANUP ---
263 let fileSizeKB = 0;
264
265 if (rawIdea.contentType === "file" && rawIdea.content) {
266 const filePath = path.join(uploadsFolder, rawIdea.content);
267
268 if (fs.existsSync(filePath)) {
269 const stats = fs.statSync(filePath);
270 fileSizeKB = Math.ceil(stats.size / 1024);
271
272 fs.unlinkSync(filePath);
273 fileDeleted = true;
274 }
275 }
276
277 // --- SOFT DELETE via status ---
278 rawIdea.status = "deleted";
279 rawIdea.content = fileDeleted
280 ? "File was deleted"
281 : rawIdea.contentType === "text"
282 ? rawIdea.content
283 : "File was deleted";
284
285 await rawIdea.save();
286
287 if (fileSizeKB > 0) {
288 await User.findByIdAndUpdate(userId, [
289 {
290 $set: {
291 "metadata.storage.usageKB": {
292 $max: [{ $subtract: [{ $ifNull: ["$metadata.storage.usageKB", 0] }, fileSizeKB] }, 0],
293 },
294 },
295 },
296 ]);
297 }
298
299 // --- LOG CONTRIBUTION ---
300 await logContribution({
301 userId,
302 nodeId: DELETED,
303 wasAi,
304 action: "rawIdea",
305 nodeVersion: DELETED,
306 rawIdeaAction: {
307 action: "delete",
308 rawIdeaId: rawIdeaId.toString(),
309 },
310 ...(energyUsed ? { energyUsed } : {}),
311 });
312
313 return {
314 message: fileDeleted
315 ? "Raw idea deleted and file removed"
316 : "Raw idea deleted",
317 };
318}
319
320/**
321 * @param {string} [status="pending"] - "pending"|"processing"|"succeeded"|"stuck"|"deleted"|"all"
322 * "pending" also includes legacy docs with no status field.
323 */
324async function getRawIdeas({
325 userId,
326 limit,
327 startDate,
328 endDate,
329 status = "pending",
330}) {
331 if (!userId) {
332 throw new Error("Missing required parameter: userId");
333 }
334
335 if (limit !== undefined && (typeof limit !== "number" || limit <= 0)) {
336 throw new Error("Invalid limit: must be a positive number");
337 }
338
339 const queryObj = { userId };
340
341 if (!status || status === "all") {
342 // no status filter — return everything for this user
343 } else if (status === "pending") {
344 // include legacy docs that predate the status field
345 queryObj.$or = [
346 { status: "pending" },
347 { status: null },
348 { status: { $exists: false } },
349 ];
350 } else {
351 queryObj.status = status;
352 }
353
354 if (startDate || endDate) {
355 queryObj.createdAt = {};
356 if (startDate) queryObj.createdAt.$gte = new Date(startDate);
357 if (endDate) queryObj.createdAt.$lte = new Date(endDate);
358 }
359
360 const sortField =
361 status === "succeeded" ? { placedAt: -1 } : { createdAt: -1 };
362
363 let query = RawIdea.find(queryObj)
364 .sort(sortField)
365 .populate("tagged", "username")
366 .lean();
367
368 if (typeof limit === "number") {
369 query = query.limit(limit);
370 }
371
372 const rawIdeas = await query;
373
374 return {
375 message: "Raw ideas retrieved successfully",
376 rawIdeas,
377 };
378}
379
380function escapeRegex(str) {
381 return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
382}
383
384function wordify(str) {
385 return str
386 .replace(/-/g, " ") // hyphen becomes space
387 .replace(/[^\w\s]/g, "") // remove punctuation
388 .trim();
389}
390async function searchRawIdeasByUser({
391 userId,
392 query,
393 limit,
394 startDate,
395 endDate,
396 status = "pending",
397}) {
398 if (!userId) throw new Error("Missing required parameter: userId");
399 if (!query || typeof query !== "string") {
400 throw new Error("Query must be a non-empty string");
401 }
402
403 let conditions = [];
404
405 // --- 1. Exact phrase: "some phrase"
406 const phraseMatch = query.match(/"(.*?)"/);
407 if (phraseMatch) {
408 const phrase = escapeRegex(phraseMatch[1]);
409 conditions.push({
410 content: new RegExp(phrase, "i"),
411 });
412 }
413
414 // Remove the phrase part and split rest
415 const cleaned = query.replace(/"(.*?)"/, "").trim();
416 if (cleaned.length > 0) {
417 const processed = wordify(cleaned);
418 const words = processed.split(/\s+/).filter(Boolean);
419
420 for (const w of words) {
421 const wEsc = escapeRegex(w);
422 const regex = new RegExp(`\\b${wEsc}\\b`, "i");
423 conditions.push({ content: regex });
424 }
425 }
426
427 // --- 3. Hyphen exact fallback
428 if (query.includes("-")) {
429 const escaped = escapeRegex(query);
430 conditions.push({
431 content: new RegExp(escaped, "i"),
432 });
433 }
434
435 const mongoQueryObj = {
436 userId, // inbox only
437 contentType: "text", // files have no searchable text
438 $and: conditions,
439 };
440
441 if (!status || status === "all") {
442 // no status filter
443 } else if (status === "pending") {
444 mongoQueryObj.$or = [
445 { status: "pending" },
446 { status: null },
447 { status: { $exists: false } },
448 ];
449 } else {
450 mongoQueryObj.status = status;
451 }
452
453 if (startDate || endDate) {
454 mongoQueryObj.createdAt = {};
455 if (startDate) mongoQueryObj.createdAt.$gte = new Date(startDate);
456 if (endDate) mongoQueryObj.createdAt.$lte = new Date(endDate);
457 }
458
459 let mongoQuery = RawIdea.find(mongoQueryObj)
460 .sort({ createdAt: -1 })
461 .populate("tagged", "username")
462 .lean();
463
464 if (limit && limit > 0) {
465 mongoQuery = mongoQuery.limit(limit);
466 }
467
468 const rawIdeas = await mongoQuery;
469
470 return {
471 message: "Raw idea search completed",
472 rawIdeas,
473 };
474}
475
476const AUTO_PLACE_ELIGIBLE = ["standard", "premium"];
477
478async function toggleAutoPlace({ userId, enabled }) {
479 if (!userId) throw new Error("Missing required parameter: userId");
480 if (typeof enabled !== "boolean")
481 throw new Error("enabled must be a boolean");
482
483 const user = await User.findById(userId).select(
484 "isAdmin metadata",
485 );
486 if (!user) throw new Error("User not found");
487
488 // Admin always has access. Otherwise check tier via user-tiers extension.
489 if (!user.isAdmin) {
490 try {
491 const tiers = getExtension("user-tiers");
492 const allowed = tiers?.exports ? await tiers.exports.hasAccess(userId, "auto-place") : true;
493 if (!allowed) {
494 throw new Error("Auto-place requires a Standard or Premium plan.");
495 }
496 } catch (err) {
497 if (err.message.includes("requires")) throw err;
498 }
499 }
500
501 const { batchSetUserMeta } = await import("../../seed/tree/userMetadata.js");
502 const rawIdeas = getUserMeta(user, "rawIdeas");
503 rawIdeas.autoPlace = enabled;
504 await batchSetUserMeta(String(user._id), "rawIdeas", rawIdeas);
505
506 return { message: `Auto-place ${enabled ? "enabled" : "disabled"}`, enabled };
507}
508
509export {
510 createRawIdea,
511 convertRawIdeaToNote,
512 deleteRawIdeaAndFile,
513 getRawIdeas,
514 searchRawIdeasByUser,
515 toggleAutoPlace,
516 AUTO_PLACE_ELIGIBLE,
517};
518
1import router from "./routes.js";
2import { resolveHtmlAuth } from "./routes.js";
3import tools from "./tools.js";
4import {
5 startRawIdeaAutoPlaceJob,
6 stopRawIdeaAutoPlaceJob,
7} from "./autoPlaceJob.js";
8import { setEnergyService } from "./core.js";
9
10import chooseRoot from "./modes/chooseRoot.js";
11import rawIdeaPlacement from "./modes/raw-idea-placement.js";
12
13export async function init(core) {
14 resolveHtmlAuth();
15 const { setServices } = await import("./core.js");
16 setServices({ models: core.models, contributions: core.contributions });
17 if (core.energy) setEnergyService(core.energy);
18 core.modes.registerMode("home:raw-idea-choose-root", chooseRoot, "raw-ideas");
19 core.modes.registerMode("home:raw-idea-placement", rawIdeaPlacement, "raw-ideas");
20 core.llm.registerUserLlmSlot?.("rawIdea");
21
22 // Register quick link on user profile
23 try {
24 const { getExtension } = await import("../loader.js");
25 const treeos = getExtension("treeos-base");
26 treeos?.exports?.registerSlot?.("user-quick-links", "raw-ideas", ({ userId, queryString }) =>
27 `<li><a href="/api/v1/user/${userId}/raw-ideas${queryString}">Raw Ideas</a></li>`,
28 { priority: 15 }
29 );
30 treeos?.exports?.registerSlot?.("user-profile-sections", "raw-ideas", ({ userId, queryString }) =>
31 `<div class="glass-card raw-ideas-section">
32 <h2>Capture a Raw Idea</h2>
33 <form method="POST" action="/api/v1/user/${userId}/raw-ideas${queryString}"
34 enctype="multipart/form-data" class="raw-idea-form" id="rawIdeaForm">
35 <textarea name="content" placeholder="What's on your mind?" id="rawIdeaInput"
36 rows="1" maxlength="5000" autofocus></textarea>
37 <div class="char-counter" id="charCounter">
38 <span id="charCount">0</span> / 5000
39 <span class="energy-display" id="energyDisplay"></span>
40 </div>
41 <div class="form-actions">
42 <div class="file-input-wrapper">
43 <input type="file" name="file" id="fileInput" />
44 <div class="file-selected-badge" id="fileSelectedBadge">
45 <span>\uD83D\uDCCE</span>
46 <span class="file-name" id="fileName"></span>
47 <button type="button" class="clear-file" id="clearFileBtn" title="Remove file">\u2715</button>
48 </div>
49 </div>
50 <button type="submit" class="send-button" title="Save raw idea" id="rawIdeaSendBtn">
51 <span class="send-label">Send</span>
52 <span class="send-progress"></span>
53 </button>
54 </div>
55 </form>
56 </div>`,
57 { priority: 10 }
58 );
59 } catch {}
60
61 return {
62 router,
63 tools,
64 jobs: [
65 {
66 name: "raw-idea-auto-place",
67 start: () => startRawIdeaAutoPlaceJob({ intervalMs: 15 * 60 * 1000 }),
68 stop: () => stopRawIdeaAutoPlaceJob(),
69 },
70 ],
71 };
72}
73
1export default {
2 name: "raw-ideas",
3 version: "1.0.1",
4 builtFor: "TreeOS",
5 description:
6 "Thoughts do not arrive organized. They arrive in the shower, on a walk, in the middle of " +
7 "something else. The raw ideas inbox is a capture layer that sits outside the tree. Text " +
8 "or file, no structure required. Drop it in and keep moving. The idea sits in your inbox " +
9 "with status pending until you or the AI decides where it belongs." +
10 "\n\n" +
11 "Placement is a multi-phase AI orchestration pipeline. Phase one: the LLM reads the raw " +
12 "idea content alongside deep summaries of every tree the user owns. It picks the best-fit " +
13 "tree with a confidence score. Below 0.35 confidence the idea is marked stuck because " +
14 "forcing bad placement is worse than waiting. Phase two: the selected tree's orchestrator " +
15 "takes over. It navigates the tree structure, finds the right branch and node, creates " +
16 "child nodes if needed, and writes the idea as a note at the correct position. Phase three: " +
17 "the raw idea is marked succeeded with a timestamp and the full path from root to target " +
18 "node is recorded in the contribution log." +
19 "\n\n" +
20 "Three entry points for placement. Manual: the user picks a node and transfers the idea " +
21 "themselves via the CLI or API. Interactive: the place endpoint runs the full pipeline and " +
22 "returns a conversational response explaining where it landed and why. Background: the " +
23 "auto-place job runs every 15 minutes, picks up the latest pending text idea for each " +
24 "eligible user who is offline, and fires the pipeline silently. Users toggle auto-place " +
25 "on or off. The job skips users who are currently online because they can trigger it " +
26 "themselves." +
27 "\n\n" +
28 "Ideas can be deferred to short-term memory when the tree orchestrator determines the idea " +
29 "needs more context before placement. Status lifecycle: pending, processing, succeeded, " +
30 "stuck, deferred, deleted. @mentions in text ideas resolve to real users. File ideas " +
31 "support upload with storage tracking. Search supports exact phrase matching, word boundary " +
32 "matching, and hyphenated term matching across the inbox.",
33
34 needs: {
35 services: ["llm", "session", "chat", "orchestrator", "contributions", "hooks"],
36 models: ["Node", "User", "Note"],
37 },
38
39 optional: {
40 services: ["energy"],
41 extensions: ["html-rendering", "treeos-base"],
42 },
43
44 provides: {
45 models: {
46 RawIdea: "./model.js",
47 },
48 routes: "./routes.js",
49 tools: true,
50 jobs: "./autoPlaceJob.js",
51 orchestrator: "./pipeline.js",
52 energyActions: {
53 rawIdeaPlacement: { cost: 2 },
54 },
55 sessionTypes: {
56 RAW_IDEA_ORCHESTRATE: "raw-idea-orchestrate",
57 RAW_IDEA_CHAT: "raw-idea-chat",
58 SCHEDULED_RAW_IDEA: "scheduled-raw-idea",
59 },
60 cli: [
61 { command: "ideas", scope: ["home"], description: "List raw ideas (-p pending, -a all, -q search)", method: "GET", endpoint: "/user/:userId/raw-ideas" },
62 { command: "idea <message...>", scope: ["home"], description: "AI places idea in the right tree", method: "POST", endpoint: "/user/:userId/raw-ideas/place", bodyMap: { content: 0 } },
63 { command: "idea-store <message...>", scope: ["home"], description: "Save idea without processing", method: "POST", endpoint: "/user/:userId/raw-ideas", bodyMap: { content: 0 } },
64 { command: "idea-place <rawIdeaId>", scope: ["home"], description: "Process a stored idea", method: "POST", endpoint: "/user/:userId/raw-ideas/:rawIdeaId/place" },
65 { command: "idea-transfer <rawIdeaId> <nodeId>", scope: ["home"], description: "Manually transfer idea to a node", method: "POST", endpoint: "/user/:userId/raw-ideas/:rawIdeaId/transfer", bodyMap: { nodeId: 1 } },
66 { command: "idea-auto <toggle>", scope: ["home"], description: "Toggle auto-placement (on/off)", method: "POST", endpoint: "/user/:userId/raw-ideas/auto", bodyMap: { enabled: 0 } },
67 { command: "rm-idea <id>", scope: ["home"], description: "Delete a raw idea", method: "DELETE", endpoint: "/user/:userId/raw-ideas/:id" },
68 ],
69 },
70};
71
1import mongoose from "mongoose";
2import { v4 as uuidv4 } from "uuid";
3
4const RawIdeaSchema = new mongoose.Schema({
5 _id: {
6 type: String,
7 default: uuidv4,
8 },
9 contentType: {
10 type: String,
11 enum: ["file", "text"],
12 required: true,
13 },
14 content: {
15 type: String,
16 required: true,
17 },
18 userId: {
19 type: String,
20 ref: "User",
21 required: true,
22 },
23 tagged: [
24 {
25 type: String,
26 ref: "User",
27 },
28 ],
29 createdAt: {
30 type: Date,
31 default: Date.now,
32 },
33 status: {
34 type: String,
35 enum: ["pending", "processing", "succeeded", "stuck", "deleted", "deferred"],
36 default: "pending",
37 },
38 aiSessionId: {
39 type: String,
40 default: null,
41 },
42 placedAt: {
43 type: Date,
44 default: null,
45 },
46});
47
48const RawIdea = mongoose.model("RawIdea", RawIdeaSchema);
49export default RawIdea;
50
1// extensions/raw-ideas/modes/chooseRoot.js
2// LLM reasoning mode: given a raw idea and the user's trees, pick the best-fit root.
3// Pure reasoning — no tools. All context is injected into the system prompt.
4
5export default {
6 name: "home:raw-idea-choose-root",
7 bigMode: "home",
8 hidden: true,
9 toolNames: [],
10
11 buildSystemPrompt({ username, content, rootSummaries }) {
12 const summariesBlock =
13 rootSummaries && rootSummaries.length > 0
14 ? rootSummaries
15 .map(
16 (r, i) =>
17 `Tree ${i + 1}: "${r.name}" (rootId: ${r.rootId})\n${r.summary}`,
18 )
19 .join("\n\n")
20 : "No trees available.";
21
22 return `You are a raw-idea placement assistant for ${username}.
23
24[Task]
25A raw idea needs to be placed into the most relevant tree. Analyze the idea content and each tree's structure to decide which tree it belongs in.
26
27[Raw Idea]
28${content}
29
30[Available Trees]
31${summariesBlock}
32
33[Instructions]
34- Choose the single best-fit tree for this idea.
35- Consider the tree's purpose, existing topics, and how well the idea aligns.
36- If no tree is a good fit, set confidence below 0.35.
37- Respond ONLY with valid JSON matching this exact schema:
38
39{
40 "rootId": "<the rootId string of the best tree, or null if no fit>",
41 "rootName": "<name of that tree, or null>",
42 "confidence": <number 0.0 to 1.0>,
43 "reasoning": "<one sentence explaining the choice>"
44}
45
46Do not include any text outside the JSON object.`.trim();
47 },
48};
49
1// extensions/raw-ideas/modes/raw-idea-placement.js
2// Process inbox items - review raw ideas and place them into trees
3
4export default {
5 name: "home:raw-idea-placement",
6 emoji: "💡",
7 label: "Raw Ideas",
8 bigMode: "home",
9 hidden: true,
10
11 toolNames: [
12 "get-raw-ideas-by-user",
13 "get-root-nodes",
14 "get-tree",
15 "get-node",
16 "transfer-raw-idea-to-note",
17 "create-new-node",
18 ],
19
20 buildSystemPrompt({ username, userId }) {
21 return `You are TreeOS Helper, operating in RAW IDEA PLACEMENT mode.
22
23[Context]
24- User: ${username}
25- User ID: ${userId}
26- Mode: Raw Idea Placement
27
28[What You Do]
29Help the user process their inbox of raw ideas. The workflow is:
301. Fetch raw ideas with get-raw-ideas-by-user
312. Present each idea to the user one at a time
323. For each idea, discuss where it belongs:
33 - Use get-root-nodes to show available trees
34 - Use get-tree to explore tree structure for placement
35 - Use get-node for detail on potential parent nodes
364. Place the idea:
37 - transfer-raw-idea-to-note to attach it to an existing node
38 - create-new-node if it needs a new home, then transfer
395. Move to the next idea
40
41[Available Tools]
42- get-raw-ideas-by-user: Fetch the user's inbox
43- get-root-nodes: List user's trees for placement targets
44- get-tree: Explore tree structure to find the right spot
45- get-node: Check node details before placing
46- transfer-raw-idea-to-note: Place a raw idea as a note on a node
47- create-new-node: Create a new node if no good spot exists
48
49[Rules]
50- Present one idea at a time, don't overwhelm
51- Always confirm placement before executing
52- If the user is unsure, suggest possible locations based on tree structure
53- Be concise - the user may have many ideas to process
54- Never expose internal _id fields
55- Convert times to Pacific Time Zone`.trim();
56 },
57};
58
1import path from "path";
2import mime from "mime-types";
3import { page } from "../../html-rendering/html/layout.js";
4import { esc, escapeHtml, truncate } from "../../html-rendering/html/utils.js";
5import { getLandUrl } from "../../../canopy/identity.js";
6import { getUserMeta } from "../../../seed/tree/userMetadata.js";
7import { renderMedia as _renderMedia } from "../../html-rendering/html/utils.js";
8
9// user.js always renders immediately (no lazy loading)
10const renderMedia = (fileUrl, mimeType) => _renderMedia(fileUrl, mimeType, { lazy: false });
11
12// ═══════════════════════════════════════════════════════════════════
13// Raw Ideas List - GET /user/:userId/raw-ideas
14// ═══════════════════════════════════════════════════════════════════
15export function renderRawIdeasList({ userId, user, rawIdeas, query, statusFilter, tabs, tabUrl, token, AUTO_PLACE_ELIGIBLE }) {
16 const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
17
18 const css = `
19.header-subtitle {
20 margin-bottom: 20px;
21}
22
23
24.auto-place-row {
25 display: flex;
26 align-items: center;
27 justify-content: space-between;
28 gap: 14px;
29 margin: 16px 0 0;
30 padding: 14px 16px;
31 background: #0d1117;
32 border: 1px solid #232a38;
33 border-radius: 10px;
34}
35.auto-place-label {
36 font-size: 13px;
37 font-weight: 600;
38 color: #e6e8eb;
39}
40.auto-place-hint {
41 font-size: 11px;
42 color: #5d6371;
43 margin-top: 2px;
44}
45.auto-place-toggle {
46 position: relative;
47 width: 44px; height: 24px;
48 border-radius: 999px;
49 background: #232a38;
50 border: 1px solid #2f3849;
51 cursor: pointer;
52 transition: background 200ms ease, border-color 200ms ease;
53 flex-shrink: 0;
54}
55.auto-place-toggle.active {
56 background: rgba(125, 211, 133, 0.35);
57 border-color: rgba(125, 211, 133, 0.6);
58}
59.auto-place-toggle.muted {
60 opacity: 0.4;
61 cursor: not-allowed;
62}
63.auto-place-toggle-knob {
64 position: absolute;
65 top: 3px; left: 3px;
66 width: 16px; height: 16px;
67 border-radius: 50%;
68 background: #9ba1ad;
69 transition: left 200ms cubic-bezier(0.22, 1, 0.36, 1), background 200ms ease;
70}
71.auto-place-toggle.active .auto-place-toggle-knob {
72 left: 22px;
73 background: #7dd385;
74}
75
76@keyframes waterDrift {
77 0% { transform: translateY(-1px); }
78 100% { transform: translateY(1px); }
79}
80
81/* Search Form */
82.search-form {
83 display: flex;
84 gap: 10px;
85 flex-wrap: wrap;
86}
87
88.search-form input[type="text"] {
89 flex: 1;
90 min-width: 200px;
91 padding: 10px 14px;
92 font-size: 14px;
93 border-radius: 10px;
94 border: 1px solid #232a38;
95 background: #0d1117;
96 font-family: inherit;
97 color: #e6e8eb;
98 font-weight: 500;
99 transition: border-color 150ms ease;
100}
101
102.search-form input[type="text"]::placeholder {
103 color: #5d6371;
104}
105
106.search-form input[type="text"]:focus {
107 outline: none;
108 border-color: rgba(125, 211, 133, 0.5);
109 box-shadow: 0 0 0 3px rgba(125, 211, 133, 0.15);
110}
111
112.search-form button {
113 padding: 10px 20px;
114 font-size: 13px;
115 font-weight: 600;
116 border-radius: 10px;
117 border: 1px solid #232a38;
118 background: #161b24;
119 color: #c4c8d0;
120 cursor: pointer;
121 transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
122 font-family: inherit;
123 white-space: nowrap;
124}
125
126.search-form button:hover {
127 background: #1c222e;
128 border-color: #2f3849;
129 color: #e6e8eb;
130}
131
132/* Glass Ideas List */
133.ideas-list {
134 list-style: none;
135 display: flex;
136 flex-direction: column;
137 gap: 16px;
138}
139
140.idea-card {
141 position: relative;
142 background: #161b24;
143 border-radius: 10px;
144 padding: 18px 22px;
145 border: 1px solid #232a38;
146 border-left: 3px solid #232a38;
147 transition: background 150ms ease, border-color 150ms ease;
148 color: #e6e8eb;
149 animation: fadeInUp 0.3s ease-out both;
150}
151
152.idea-card:nth-child(1) { animation-delay: 0.05s; }
153.idea-card:nth-child(2) { animation-delay: 0.10s; }
154.idea-card:nth-child(3) { animation-delay: 0.15s; }
155.idea-card:nth-child(4) { animation-delay: 0.20s; }
156.idea-card:nth-child(5) { animation-delay: 0.25s; }
157.idea-card:nth-child(n+6) { animation-delay: 0.3s; }
158
159.idea-card:hover {
160 background: #1c222e;
161 border-color: #2f3849;
162 border-left-color: rgba(125, 211, 133, 0.55);
163}
164
165.delete-button {
166 position: absolute;
167 top: 16px;
168 right: 16px;
169 background: transparent;
170 border: 1px solid #232a38;
171 border-radius: 6px;
172 width: 26px;
173 height: 26px;
174 display: flex;
175 align-items: center;
176 justify-content: center;
177 font-size: 14px;
178 cursor: pointer;
179 color: #5d6371;
180 padding: 0;
181 line-height: 1;
182 transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
183 z-index: 10;
184}
185
186.delete-button:hover {
187 background: rgba(201, 126, 106, 0.12);
188 color: #c97e6a;
189 border-color: rgba(201, 126, 106, 0.4);
190}
191
192.idea-content {
193 padding-right: 48px;
194 margin-bottom: 16px;
195}
196
197.idea-link {
198 color: #e6e8eb;
199 text-decoration: none;
200 font-size: 14px;
201 line-height: 1.6;
202 display: block;
203 word-wrap: break-word;
204 transition: color 150ms ease;
205 font-weight: 400;
206}
207
208.idea-link:hover {
209 color: #9ce0a2;
210}
211
212.file-badge {
213 display: inline-block;
214 padding: 3px 8px;
215 background: #232a38;
216 color: #9ba1ad;
217 border-radius: 5px;
218 font-size: 10px;
219 font-weight: 600;
220 margin-right: 8px;
221 border: 1px solid #2f3849;
222 text-transform: uppercase;
223 letter-spacing: 0.5px;
224}
225
226/* Transfer Form */
227.transfer-form {
228 display: flex;
229 gap: 8px;
230 margin-top: 14px;
231 padding-top: 14px;
232 border-top: 1px solid #232a38;
233 flex-wrap: wrap;
234 align-items: center;
235}
236
237.transfer-form input[type="text"] {
238 flex: 1;
239 min-width: 180px;
240 padding: 8px 12px;
241 font-size: 13px;
242 border-radius: 8px;
243 border: 1px solid #232a38;
244 background: #0d1117;
245 font-family: inherit;
246 color: #e6e8eb;
247 font-weight: 500;
248 transition: border-color 150ms ease;
249}
250
251.transfer-form input[type="text"]::placeholder {
252 color: #5d6371;
253}
254
255.transfer-form input[type="text"]:focus {
256 outline: none;
257 border-color: rgba(125, 211, 133, 0.5);
258 box-shadow: 0 0 0 3px rgba(125, 211, 133, 0.15);
259}
260
261.transfer-form button {
262 padding: 8px 14px;
263 font-size: 12px;
264 font-weight: 600;
265 border-radius: 8px;
266 border: 1px solid #232a38;
267 background: #161b24;
268 color: #c4c8d0;
269 cursor: pointer;
270 transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
271 font-family: inherit;
272 white-space: nowrap;
273}
274
275.transfer-form button:hover {
276 background: #1c222e;
277 border-color: #2f3849;
278 color: #e6e8eb;
279}
280
281/* Metadata */
282.idea-meta {
283 margin-top: 10px;
284 font-size: 11px;
285 color: #5d6371;
286 display: flex;
287 align-items: center;
288 gap: 6px;
289}
290
291/* Status badges */
292.status-badge {
293 display: inline-block;
294 margin-left: 10px;
295 padding: 2px 8px;
296 border-radius: 20px;
297 font-size: 10px;
298 font-weight: 600;
299 vertical-align: middle;
300 letter-spacing: 0.3px;
301}
302.status-badge--pending { background: #232a38; color: #9ba1ad; border: 1px solid #2f3849; }
303.status-badge--processing { background: rgba(212, 165, 116, 0.12); color: #d4a574; border: 1px solid rgba(212, 165, 116, 0.35); }
304.status-badge--succeeded { background: rgba(125, 211, 133, 0.12); color: #9ce0a2; border: 1px solid rgba(125, 211, 133, 0.35); }
305.status-badge--stuck { background: rgba(201, 142, 90, 0.12); color: #d4a574; border: 1px solid rgba(201, 142, 90, 0.35); }
306
307/* Notices */
308.placed-notice {
309 margin-top: 10px;
310 padding: 8px 12px;
311 background: rgba(125, 211, 133, 0.08);
312 border-radius: 8px;
313 font-size: 12px;
314 color: #9ce0a2;
315 border: 1px solid rgba(125, 211, 133, 0.25);
316}
317.placed-notice .chat-link {
318 color: #9ce0a2;
319 opacity: 0.85;
320 text-decoration: underline;
321 white-space: nowrap;
322}
323.placed-notice .chat-link:hover { opacity: 1; }
324
325.stuck-notice {
326 margin-top: 10px;
327 margin-bottom: 8px;
328 padding: 8px 12px;
329 background: rgba(201, 142, 90, 0.08);
330 border-radius: 8px;
331 font-size: 12px;
332 color: #d4a574;
333 border: 1px solid rgba(201, 142, 90, 0.25);
334}
335.processing-notice {
336 margin-top: 10px;
337 padding: 8px 12px;
338 background: rgba(212, 165, 116, 0.06);
339 border-radius: 8px;
340 font-size: 12px;
341 color: #d4a574;
342 border: 1px solid rgba(212, 165, 116, 0.2);
343}
344
345/* Status filter tabs */
346.filter-tabs {
347 display: flex;
348 gap: 6px;
349 flex-wrap: wrap;
350 margin-top: 14px;
351}
352.filter-tab {
353 padding: 5px 12px;
354 border-radius: 980px;
355 font-size: 11px;
356 font-weight: 600;
357 text-decoration: none;
358 color: #9ba1ad;
359 background: #161b24;
360 border: 1px solid #232a38;
361 transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
362 letter-spacing: 0.3px;
363}
364.filter-tab:hover { background: #1c222e; color: #e6e8eb; border-color: #2f3849; }
365.filter-tab--active {
366 background: rgba(125, 211, 133, 0.1);
367 color: #9ce0a2;
368 border-color: rgba(125, 211, 133, 0.4);
369}
370
371/* Auto-place button */
372.auto-place-btn {
373 margin-top: 14px;
374 padding: 8px 16px;
375 font-size: 12px;
376 font-weight: 600;
377 border-radius: 8px;
378 border: 1px solid #232a38;
379 background: #161b24;
380 color: #c4c8d0;
381 cursor: pointer;
382 transition: background 150ms ease, border-color 150ms ease, color 150ms ease;
383 font-family: inherit;
384}
385.auto-place-btn:hover {
386 background: #1c222e;
387 border-color: #2f3849;
388 color: #e6e8eb;
389}
390.auto-place-btn:disabled {
391 opacity: 0.5;
392 cursor: not-allowed;
393}
394
395/* Responsive Design */
396@media (max-width: 640px) {
397 body {
398 padding: 16px;
399 }
400
401 .search-form {
402 flex-direction: column;
403 }
404
405 .search-form input[type="text"] {
406 width: 100%;
407 min-width: 0;
408 font-size: 16px;
409 }
410
411 .search-form button {
412 width: 100%;
413 }
414
415 .idea-card {
416 padding: 20px 16px;
417 }
418
419 .delete-button {
420 top: 16px;
421 right: 16px;
422 width: 28px;
423 height: 28px;
424 font-size: 16px;
425 }
426
427 .transfer-form {
428 flex-direction: column;
429 }
430
431 .transfer-form input[type="text"] {
432 width: 100%;
433 min-width: 0;
434 }
435
436 .transfer-form button {
437 width: 100%;
438 }
439
440}`;
441
442 const bodyHtml = `
443 <div class="container">
444 <!-- Back Navigation -->
445 <div class="back-nav">
446 <a href="/api/v1/user/${userId}${tokenQS}" class="back-link">
447 \u2190 Back to Profile
448 </a>
449 </div>
450
451 <!-- Header Section -->
452 <div class="header">
453 <h1>
454 Raw Ideas for
455<a href="/api/v1/user/${userId}${tokenQS}">${escapeHtml(user.username)}</a>
456 </h1>
457 <div class="header-subtitle">
458These will be placed onto your trees automatically while you dream (Standard+ plans)</div>
459
460 <div class="auto-place-row">
461 <div>
462 <div class="auto-place-label">Auto-place ideas</div>
463 <div class="auto-place-hint">${
464 AUTO_PLACE_ELIGIBLE.includes((getUserMeta(user, "tiers").plan || "basic"))
465 ? "Pending ideas are placed automatically every 15 minutes while you're offline."
466 : "Available on Standard, Premium, and God plans."
467 }</div>
468 </div>
469 <div
470 id="autoPlaceToggle"
471 class="auto-place-toggle${getUserMeta(user, "rawIdeas")?.autoPlace !== false ? " active" : ""}${!AUTO_PLACE_ELIGIBLE.includes((getUserMeta(user, "tiers").plan || "basic")) ? " muted" : ""}"
472 onclick="${AUTO_PLACE_ELIGIBLE.includes((getUserMeta(user, "tiers").plan || "basic")) ? "toggleAutoPlace()" : ""}"
473 >
474 <div class="auto-place-toggle-knob"></div>
475 </div>
476 </div>
477
478 <!-- Search Form -->
479 <form method="GET" action="/api/v1/user/${userId}/raw-ideas" class="search-form">
480 <input type="hidden" name="token" value="${esc(token)}">
481 <input type="hidden" name="html" value="">
482 ${statusFilter !== "pending" ? `<input type="hidden" name="status" value="${statusFilter}">` : ""}
483 <input
484 type="text"
485 name="q"
486 placeholder="Search raw ideas..."
487 value="${query.replace(/"/g, """)}"
488 />
489 <button type="submit">Search</button>
490 </form>
491
492 <!-- Status Filter Tabs -->
493 <div class="filter-tabs">
494 ${tabs.map((t) => `<a href="${tabUrl(t.key)}" class="filter-tab${statusFilter === t.key ? " filter-tab--active" : ""}">${t.label}</a>`).join("")}
495 </div>
496 </div>
497
498 <!-- Raw Ideas List -->
499 ${
500 rawIdeas.length > 0
501 ? `
502 <ul class="ideas-list">
503 ${rawIdeas
504 .map(
505 (r) => `
506 <li class="idea-card idea-card--${r.status || "pending"}" data-raw-idea-id="${r._id}" data-status="${r.status || "pending"}">
507 ${!r.status || r.status === "pending" || r.status === "stuck" ? `<button class="delete-button" title="Delete raw idea">\u2715</button>` : ""}
508
509 <div class="idea-content">
510 <a
511 href="/api/v1/user/${userId}/raw-ideas/${r._id}${tokenQS}"
512 class="idea-link"
513 >
514 ${
515 r.contentType === "file"
516 ? `<span class="file-badge">FILE</span>${escapeHtml(r.content)}`
517 : escapeHtml(r.content)
518 }
519 </a>
520 <span class="status-badge status-badge--${r.status || "pending"}">
521 ${r.status === "processing" ? "\u23F3 processing" : r.status === "succeeded" ? "\u2713 placed by AI" : r.status === "stuck" ? "\u26A0 stuck" : r.status === "deleted" ? "deleted" : "pending"}
522 </span>
523 </div>
524
525 ${
526 r.status === "succeeded"
527 ? `
528 <div class="placed-notice">Placed automatically by AI${r.placedAt ? ` on ${new Date(r.placedAt).toLocaleString()}` : ""}.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${encodeURIComponent(token)}` : ""}&html">View AI chat \u2192</a>` : ""}</div>
529 `
530 : r.status === "processing"
531 ? `
532 <div class="processing-notice">Being processed by AI \u2014 please wait.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${encodeURIComponent(token)}` : ""}&html">View AI chat \u2192</a>` : ""}</div>
533 `
534 : r.status === "deleted"
535 ? ``
536 : `
537 ${r.status === "stuck" ? `<div class="stuck-notice">Auto-placement failed \u2014 place manually below.${r.aiSessionId ? ` <a class="chat-link" href="/api/v1/user/${userId}/chats?sessionId=${r.aiSessionId}${token ? `&token=${encodeURIComponent(token)}` : ""}&html">View AI chat \u2192</a>` : ""}</div>` : ""}
538
539 ${
540 (!r.status || r.status === "pending") && r.contentType !== "file"
541 ? `
542 <button
543 class="auto-place-btn"
544 data-raw-idea-id="${r._id}"
545 data-token="${esc(token)}"
546 data-user-id="${userId}"
547 >\u2728 Auto-place</button>
548 `
549 : ""
550 }
551
552 <form
553 method="POST"
554 action="/api/v1/user/${userId}/raw-ideas/${
555 r._id
556 }/transfer?token=${encodeURIComponent(token)}&html"
557 class="transfer-form"
558 >
559 <input
560 type="text"
561 name="nodeId"
562 placeholder="Target node ID"
563 required
564 />
565 <button type="submit">Transfer to Node</button>
566 </form>
567 `
568 }
569
570 <div class="idea-meta">
571 ${new Date(r.createdAt).toLocaleString()}
572 </div>
573 </li>
574 `,
575 )
576 .join("")}
577 </ul>
578 `
579 : `
580 <div class="empty-state">
581 <div class="empty-state-icon">\uD83D\uDCAD</div>
582 <div class="empty-state-text">No ${statusFilter === "pending" ? "" : statusFilter + " "}raw ideas</div>
583 <div class="empty-state-subtext">
584 ${
585 query.trim() !== ""
586 ? "Try a different search term"
587 : statusFilter === "pending"
588 ? "Start capturing your ideas from the user page"
589 : "Nothing here yet"
590 }
591 </div>
592 </div>
593 `
594 }
595 </div>`;
596
597 const js = `
598 const urlToken = new URLSearchParams(window.location.search).get("token") || "";
599 const tokenQs = urlToken ? "?token=" + encodeURIComponent(urlToken) : "";
600
601 // Auto-refresh if any card is processing
602 if (document.querySelector(".idea-card[data-status='processing']")) {
603 setTimeout(() => window.location.reload(), 3000);
604 }
605
606 document.addEventListener("click", async function(e) {
607 // Delete
608 const deleteBtn = e.target.closest(".delete-button");
609 if (deleteBtn) {
610 e.preventDefault();
611 e.stopPropagation();
612
613 const card = deleteBtn.closest(".idea-card");
614 if (!card) return;
615 const rawIdeaId = card.dataset.rawIdeaId;
616
617 if (!confirm("Delete this raw idea? This cannot be undone.")) return;
618
619 try {
620 const res = await fetch(
621 "/api/v1/user/${userId}/raw-ideas/" + rawIdeaId + tokenQs,
622 { method: "DELETE" }
623 );
624 const data = await res.json();
625 if (!res.ok || data.status === "error") throw new Error((data.error && data.error.message) || data.error || "Delete failed");
626
627 card.style.transition = "all 0.3s ease";
628 card.style.opacity = "0";
629 card.style.transform = "translateX(-20px)";
630 setTimeout(() => card.remove(), 300);
631 } catch (err) {
632 alert("Failed to delete: " + (err.message || "Unknown error"));
633 }
634 return;
635 }
636
637 // Auto-place
638 const autoBtn = e.target.closest(".auto-place-btn");
639 if (autoBtn) {
640 e.preventDefault();
641 const rawIdeaId = autoBtn.dataset.rawIdeaId;
642 const card = autoBtn.closest(".idea-card");
643
644 autoBtn.disabled = true;
645 autoBtn.textContent = "\u23F3 Starting\u2026";
646
647 try {
648 const res = await fetch(
649 "/api/v1/user/${userId}/raw-ideas/" + rawIdeaId + "/place" + tokenQs,
650 { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ source: "user" }) }
651 );
652 if (res.status === 202) {
653 card.dataset.status = "processing";
654 // Update badge
655 const badge = card.querySelector(".status-badge");
656 if (badge) { badge.className = "status-badge status-badge--processing"; badge.textContent = "\u23F3 processing"; }
657 autoBtn.textContent = "\u23F3 Processing\u2026";
658 // Reload after 4s to show result
659 setTimeout(() => window.location.reload(), 4000);
660 } else {
661 const data = await res.json().catch(() => ({}));
662 autoBtn.disabled = false;
663 autoBtn.textContent = "\u2728 Auto-place";
664 alert((data.error && data.error.message) || data.error || "Could not start orchestration");
665 }
666 } catch (err) {
667 autoBtn.disabled = false;
668 autoBtn.textContent = "\u2728 Auto-place";
669 alert("Error: " + (err.message || "Unknown"));
670 }
671 return;
672 }
673 }, true);
674
675 async function toggleAutoPlace() {
676 var toggle = document.getElementById("autoPlaceToggle");
677 if (!toggle || toggle.classList.contains("muted")) return;
678 var isActive = toggle.classList.contains("active");
679 var newEnabled = !isActive;
680 toggle.classList.toggle("active");
681 try {
682 var res = await fetch(
683 "/api/v1/user/${userId}/raw-ideas/auto-place" + tokenQs,
684 { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: newEnabled }) }
685 );
686 var data = await res.json();
687 if (!res.ok || data.status === "error") {
688 toggle.classList.toggle("active");
689 alert((data.error && data.error.message) || data.error || "Failed to toggle");
690 }
691 } catch (err) {
692 toggle.classList.toggle("active");
693 alert("Error: " + (err.message || "Unknown"));
694 }
695 }`;
696
697 return page({
698 title: `${escapeHtml(user.username)} -- Raw Ideas`,
699 css,
700 body: bodyHtml,
701 js,
702 });
703}
704
705// ═══════════════════════════════════════════════════════════════════
706// Single Raw Idea (text) - GET /user/:userId/raw-ideas/:rawIdeaId
707// ═══════════════════════════════════════════════════════════════════
708export function renderRawIdeaText({ userId, rawIdea, back, backText, userLink, hasToken, token }) {
709 const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
710
711 const css = `
712
713 /* Raw Idea Card */
714 .raw-idea-card {
715 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
716 backdrop-filter: blur(22px) saturate(140%);
717 -webkit-backdrop-filter: blur(22px) saturate(140%);
718 border-radius: 16px;
719 padding: 32px;
720 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
721 inset 0 1px 0 rgba(255, 255, 255, 0.25);
722 border: 1px solid rgba(255, 255, 255, 0.28);
723 position: relative;
724 overflow: hidden;
725 animation: fadeInUp 0.6s ease-out 0.1s both;
726 }
727
728 /* User Info */
729 .user-info {
730 display: flex;
731 align-items: center;
732 gap: 8px;
733 margin-bottom: 20px;
734 padding-bottom: 16px;
735 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
736 }
737
738 .user-info::before {
739 content: '\uD83D\uDCA1';
740 font-size: 18px;
741 }
742
743 .user-info a {
744 color: white;
745 text-decoration: none;
746 font-weight: 600;
747 font-size: 15px;
748 transition: all 0.2s;
749 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
750 }
751
752 .user-info a:hover {
753 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
754 transform: translateX(2px);
755 }
756
757 .note-time {
758 margin-left: auto;
759 font-size: 13px;
760 color: rgba(255, 255, 255, 0.6);
761 font-weight: 400;
762 }
763
764 /* Status badge */
765 .status-row {
766 display: flex;
767 align-items: center;
768 gap: 10px;
769 margin-bottom: 16px;
770 flex-wrap: wrap;
771 }
772 .status-badge {
773 display: inline-block;
774 padding: 4px 12px;
775 border-radius: 20px;
776 font-size: 12px;
777 font-weight: 600;
778 letter-spacing: 0.03em;
779 }
780 .status-badge--pending { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.2); }
781 .status-badge--processing{ background: rgba(255,200,0,0.25); color: #ffe066; border: 1px solid rgba(255,200,0,0.3); }
782 .status-badge--succeeded { background: rgba(50,220,120,0.25); color: #7effc0; border: 1px solid rgba(50,220,120,0.3); }
783 .status-badge--stuck { background: rgba(255,140,0,0.25); color: #ffcf7e; border: 1px solid rgba(255,140,0,0.3); }
784 .status-badge--deleted { background: rgba(255,80,80,0.2); color: #ff9ea0; border: 1px solid rgba(255,80,80,0.25); }
785 .ai-chat-link {
786 display: inline-block;
787 padding: 4px 12px;
788 border-radius: 20px;
789 font-size: 12px;
790 font-weight: 600;
791 background: rgba(255,255,255,0.15);
792 color: rgba(255,255,255,0.9);
793 border: 1px solid rgba(255,255,255,0.25);
794 text-decoration: none;
795 transition: background 0.2s;
796 }
797 .ai-chat-link:hover { background: rgba(255,255,255,0.25); }
798
799 /* Copy Button Bar */
800 .copy-bar {
801 display: flex;
802 justify-content: flex-end;
803 gap: 8px;
804 margin-bottom: 16px;
805 }
806
807 .copy-btn {
808 background: rgba(255, 255, 255, 0.2);
809 backdrop-filter: blur(10px);
810 border: 1px solid rgba(255, 255, 255, 0.3);
811 cursor: pointer;
812 font-size: 20px;
813 padding: 8px 12px;
814 border-radius: 980px;
815 transition: all 0.3s;
816 position: relative;
817 overflow: hidden;
818 }
819
820 .copy-btn::before {
821 content: "";
822 position: absolute;
823 inset: -40%;
824 background: radial-gradient(
825 120% 60% at 0% 0%,
826 rgba(255, 255, 255, 0.35),
827 transparent 60%
828 );
829 opacity: 0;
830 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
831 pointer-events: none;
832 }
833
834 .copy-btn:hover {
835 background: rgba(255, 255, 255, 0.3);
836 transform: translateY(-2px);
837 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
838 }
839
840 .copy-btn:hover::before {
841 opacity: 1;
842 transform: translateX(30%) translateY(10%);
843 }
844
845 .copy-btn:active {
846 transform: translateY(0);
847 }
848
849 #copyUrlBtn {
850 background: rgba(255, 255, 255, 0.25);
851 }
852
853 /* Raw Idea Content */
854 pre {
855 background: rgba(255, 255, 255, 0.3);
856 backdrop-filter: blur(20px) saturate(150%);
857 -webkit-backdrop-filter: blur(20px) saturate(150%);
858 padding: 20px;
859 border-radius: 12px;
860 font-size: 16px;
861 line-height: 1.7;
862 white-space: pre-wrap;
863 word-wrap: break-word;
864 border: 1px solid rgba(255, 255, 255, 0.3);
865 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
866 color: #3d2f8f;
867 font-weight: 600;
868 text-shadow:
869 0 0 10px rgba(102, 126, 234, 0.4),
870 0 1px 3px rgba(255, 255, 255, 1);
871 box-shadow:
872 0 4px 20px rgba(0, 0, 0, 0.1),
873 inset 0 1px 0 rgba(255, 255, 255, 0.4);
874 position: relative;
875 overflow: hidden;
876 transition: all 0.3s ease;
877 }
878
879 pre::before {
880 content: "";
881 position: absolute;
882 inset: 0;
883 background: linear-gradient(
884 110deg,
885 transparent 40%,
886 rgba(255, 255, 255, 0.4),
887 transparent 60%
888 );
889 opacity: 0;
890 transform: translateX(-100%);
891 pointer-events: none;
892 }
893
894 pre:hover {
895 border-color: rgba(255, 255, 255, 0.5);
896 box-shadow:
897 0 8px 32px rgba(102, 126, 234, 0.2),
898 inset 0 1px 0 rgba(255, 255, 255, 0.6);
899 }
900
901 pre.flash::before {
902 opacity: 1;
903 animation: glassShimmer 1.2s ease forwards;
904 }
905
906 pre:hover::before {
907 opacity: 1;
908 animation: glassShimmer 1.2s ease forwards;
909 }
910
911 pre.copied {
912 animation: textGlow 0.8s ease-out;
913 }
914
915 @keyframes textGlow {
916 0% {
917 box-shadow:
918 0 4px 20px rgba(0, 0, 0, 0.1),
919 inset 0 1px 0 rgba(255, 255, 255, 0.4);
920 }
921 50% {
922 box-shadow:
923 0 0 40px rgba(102, 126, 234, 0.6),
924 0 0 60px rgba(102, 126, 234, 0.4),
925 inset 0 1px 0 rgba(255, 255, 255, 0.8);
926 text-shadow:
927 0 0 20px rgba(102, 126, 234, 0.8),
928 0 0 30px rgba(102, 126, 234, 0.6),
929 0 1px 3px rgba(255, 255, 255, 1);
930 }
931 100% {
932 box-shadow:
933 0 4px 20px rgba(0, 0, 0, 0.1),
934 inset 0 1px 0 rgba(255, 255, 255, 0.4);
935 }
936 }
937
938 @keyframes glassShimmer {
939 0% {
940 opacity: 0;
941 transform: translateX(-120%) skewX(-15deg);
942 }
943 50% {
944 opacity: 1;
945 }
946 100% {
947 opacity: 0;
948 transform: translateX(120%) skewX(-15deg);
949 }
950 }
951
952 /* Responsive */`;
953
954 const bodyHtml = `
955 <div class="container">
956 <!-- Back Navigation -->
957 <div class="back-nav">
958<a href="${back}" class="back-link">${backText}</a>
959 <button id="copyUrlBtn" class="copy-btn" title="Copy URL to share">\uD83D\uDD17</button>
960 </div>
961
962 <!-- Raw Idea Card -->
963 <div class="raw-idea-card">
964 <div class="user-info">
965 ${userLink}
966 ${rawIdea.createdAt ? `<span class="note-time">${new Date(rawIdea.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(rawIdea.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
967 </div>
968
969 ${
970 hasToken
971 ? `<div class="status-row">
972 <span class="status-badge status-badge--${rawIdea.status || "pending"}">
973 ${rawIdea.status === "processing" ? "\u23F3 processing" : rawIdea.status === "succeeded" ? "\u2713 placed by AI" : rawIdea.status === "stuck" ? "\u26A0 stuck" : rawIdea.status === "deleted" ? "deleted" : "pending"}
974 </span>
975 ${rawIdea.aiSessionId && (rawIdea.status === "succeeded" || rawIdea.status === "stuck" || rawIdea.status === "processing") ? `<a class="ai-chat-link" href="/api/v1/user/${userId}/chats?sessionId=${rawIdea.aiSessionId}&token=${encodeURIComponent(token)}&html">View AI chat \u2192</a>` : ""}
976 </div>`
977 : ""
978 }
979
980 <div class="copy-bar">
981 <button id="copyBtn" class="copy-btn" title="Copy raw idea">\uD83D\uDCCB</button>
982 </div>
983
984 <pre id="content">${escapeHtml(rawIdea.content)}</pre>
985 </div>
986 </div>`;
987
988 const js = `
989 const copyBtn = document.getElementById("copyBtn");
990 const copyUrlBtn = document.getElementById("copyUrlBtn");
991 const content = document.getElementById("content");
992
993 copyBtn.addEventListener("click", () => {
994 navigator.clipboard.writeText(content.textContent).then(() => {
995 copyBtn.textContent = "\u2714\uFE0F";
996 setTimeout(() => (copyBtn.textContent = "\uD83D\uDCCB"), 900);
997
998 content.classList.add("copied");
999 setTimeout(() => content.classList.remove("copied"), 800);
1000
1001 setTimeout(() => {
1002 content.classList.remove("flash");
1003 void content.offsetWidth;
1004 content.classList.add("flash");
1005 setTimeout(() => content.classList.remove("flash"), 1300);
1006 }, 600);
1007 });
1008 });
1009
1010 copyUrlBtn.addEventListener("click", () => {
1011 const url = new URL(window.location.href);
1012 url.searchParams.delete('token');
1013 if (!url.searchParams.has('html')) {
1014 url.searchParams.set('html', '');
1015 }
1016 navigator.clipboard.writeText(url.toString()).then(() => {
1017 copyUrlBtn.textContent = "\u2714\uFE0F";
1018 setTimeout(() => (copyUrlBtn.textContent = "\uD83D\uDD17"), 900);
1019 });
1020 });`;
1021
1022 return page({
1023 title: `Raw Idea by ${escapeHtml(rawIdea.userId?.username || "User")} - TreeOS`,
1024 css,
1025 body: `
1026 <meta name="description" content="${escapeHtml((rawIdea.content || "").slice(0, 160))}" />
1027 <meta property="og:title" content="Raw Idea by ${escapeHtml(rawIdea.userId?.username || "User")} - TreeOS" />
1028 <meta property="og:description" content="${escapeHtml((rawIdea.content || "").slice(0, 160))}" />
1029 <meta property="og:type" content="article" />
1030 <meta property="og:site_name" content="TreeOS" />
1031 <meta property="og:image" content="${getLandUrl()}/tree.png" />
1032` + bodyHtml,
1033 js,
1034 });
1035}
1036
1037// ═══════════════════════════════════════════════════════════════════
1038// Single Raw Idea (file) - GET /user/:userId/raw-ideas/:rawIdeaId
1039// ═══════════════════════════════════════════════════════════════════
1040export function renderRawIdeaFile({ userId, rawIdea, back, backText, userLink, hasToken, token }) {
1041 const tokenQS = token ? `?token=${encodeURIComponent(token)}&html` : `?html`;
1042 const fileDeleted = rawIdea.content === "File was deleted";
1043 const fileUrl = fileDeleted ? "" : `/api/v1/uploads/${rawIdea.content}`;
1044 const mimeType = fileDeleted
1045 ? ""
1046 : mime.lookup(rawIdea.content) || "application/octet-stream";
1047 const mediaHtml = fileDeleted ? "" : renderMedia(fileUrl, mimeType);
1048 const fileName = fileDeleted ? "File was deleted" : rawIdea.content;
1049
1050 const css = `
1051
1052 /* File Card */
1053 .file-card {
1054 background: rgba(var(--glass-water-rgb), var(--glass-alpha));
1055 backdrop-filter: blur(22px) saturate(140%);
1056 -webkit-backdrop-filter: blur(22px) saturate(140%);
1057 border-radius: 16px;
1058 padding: 32px;
1059 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12),
1060 inset 0 1px 0 rgba(255, 255, 255, 0.25);
1061 border: 1px solid rgba(255, 255, 255, 0.28);
1062 position: relative;
1063 overflow: hidden;
1064 animation: fadeInUp 0.6s ease-out 0.1s both;
1065 }
1066
1067 /* User Info */
1068 .user-info {
1069 display: flex;
1070 align-items: center;
1071 gap: 8px;
1072 margin-bottom: 20px;
1073 padding-bottom: 16px;
1074 border-bottom: 1px solid rgba(255, 255, 255, 0.2);
1075 }
1076
1077 .user-info::before {
1078 content: '\uD83D\uDC64';
1079 font-size: 18px;
1080 }
1081
1082 .user-info a {
1083 color: white;
1084 text-decoration: none;
1085 font-weight: 600;
1086 font-size: 15px;
1087 transition: all 0.2s;
1088 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1089 }
1090
1091 .user-info a:hover {
1092 text-shadow: 0 0 12px rgba(255, 255, 255, 0.8);
1093 transform: translateX(2px);
1094 }
1095
1096 .note-time {
1097 margin-left: auto;
1098 font-size: 13px;
1099 color: rgba(255, 255, 255, 0.6);
1100 font-weight: 400;
1101 }
1102
1103 /* File Header */
1104 h1 {
1105 font-size: 24px;
1106 font-weight: 700;
1107 color: white;
1108 margin-bottom: 20px;
1109 word-break: break-word;
1110 text-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
1111 }
1112
1113 /* Action Buttons */
1114 .action-bar {
1115 display: flex;
1116 gap: 12px;
1117 margin-bottom: 24px;
1118 flex-wrap: wrap;
1119 }
1120
1121 .download {
1122 display: inline-flex;
1123 align-items: center;
1124 gap: 8px;
1125 padding: 12px 20px;
1126 background: rgba(255, 255, 255, 0.25);
1127 backdrop-filter: blur(10px);
1128 color: white;
1129 text-decoration: none;
1130 border-radius: 980px;
1131 font-weight: 600;
1132 font-size: 15px;
1133 transition: all 0.3s;
1134 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
1135 border: 1px solid rgba(255, 255, 255, 0.3);
1136 cursor: pointer;
1137 position: relative;
1138 overflow: hidden;
1139 }
1140
1141 .download::after {
1142 content: '\u2B07\uFE0F';
1143 font-size: 16px;
1144 margin-left: 4px;
1145 }
1146
1147 .download::before {
1148 content: "";
1149 position: absolute;
1150 inset: -40%;
1151 background: radial-gradient(
1152 120% 60% at 0% 0%,
1153 rgba(255, 255, 255, 0.35),
1154 transparent 60%
1155 );
1156 opacity: 0;
1157 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1158 pointer-events: none;
1159 }
1160
1161 .download:hover {
1162 background: rgba(255, 255, 255, 0.35);
1163 transform: translateY(-2px);
1164 box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
1165 }
1166
1167 .download:hover::before {
1168 opacity: 1;
1169 transform: translateX(30%) translateY(10%);
1170 }
1171
1172 .copy-url-btn {
1173 display: inline-flex;
1174 align-items: center;
1175 gap: 8px;
1176 padding: 12px 20px;
1177 background: rgba(255, 255, 255, 0.2);
1178 backdrop-filter: blur(10px);
1179 color: white;
1180 border: 1px solid rgba(255, 255, 255, 0.3);
1181 border-radius: 980px;
1182 font-weight: 600;
1183 font-size: 15px;
1184 transition: all 0.3s;
1185 cursor: pointer;
1186 position: relative;
1187 overflow: hidden;
1188 }
1189
1190 .copy-url-btn::after {
1191 content: '\uD83D\uDD17';
1192 font-size: 16px;
1193 margin-left: 4px;
1194 }
1195
1196 .copy-url-btn::before {
1197 content: "";
1198 position: absolute;
1199 inset: -40%;
1200 background: radial-gradient(
1201 120% 60% at 0% 0%,
1202 rgba(255, 255, 255, 0.35),
1203 transparent 60%
1204 );
1205 opacity: 0;
1206 transition: opacity 0.35s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
1207 pointer-events: none;
1208 }
1209
1210 .copy-url-btn:hover {
1211 background: rgba(255, 255, 255, 0.3);
1212 transform: translateY(-2px);
1213 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1214 }
1215
1216 .copy-url-btn:hover::before {
1217 opacity: 1;
1218 transform: translateX(30%) translateY(10%);
1219 }
1220
1221 /* Media Container */
1222 .media {
1223 margin-top: 24px;
1224 padding-top: 24px;
1225 border-top: 1px solid rgba(255, 255, 255, 0.2);
1226 }
1227
1228 .media img,
1229 .media video,
1230 .media audio {
1231 max-width: 100%;
1232 border-radius: 12px;
1233 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
1234 border: 1px solid rgba(255, 255, 255, 0.2);
1235 }
1236
1237 /* Responsive */
1238
1239
1240 .status-row {
1241 display: flex;
1242 align-items: center;
1243 gap: 10px;
1244 margin-bottom: 16px;
1245 flex-wrap: wrap;
1246 }
1247 .status-badge {
1248 display: inline-block;
1249 padding: 4px 12px;
1250 border-radius: 20px;
1251 font-size: 12px;
1252 font-weight: 600;
1253 letter-spacing: 0.03em;
1254 }
1255 .status-badge--pending { background: rgba(255,255,255,0.15); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.2); }
1256 .status-badge--processing{ background: rgba(255,200,0,0.25); color: #ffe066; border: 1px solid rgba(255,200,0,0.3); }
1257 .status-badge--succeeded { background: rgba(50,220,120,0.25); color: #7effc0; border: 1px solid rgba(50,220,120,0.3); }
1258 .status-badge--stuck { background: rgba(255,140,0,0.25); color: #ffcf7e; border: 1px solid rgba(255,140,0,0.3); }
1259 .status-badge--deleted { background: rgba(255,80,80,0.2); color: #ff9ea0; border: 1px solid rgba(255,80,80,0.25); }`;
1260
1261 const bodyHtml = `
1262 <div class="container">
1263 <!-- Back Navigation -->
1264 <div class="back-nav">
1265<a href="${back}" class="back-link">${backText}</a>
1266 </div>
1267
1268 <!-- File Card -->
1269 <div class="file-card">
1270 <div class="user-info">
1271 ${userLink}
1272 ${rawIdea.createdAt ? `<span class="note-time">${new Date(rawIdea.createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} at ${new Date(rawIdea.createdAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}</span>` : ""}
1273 </div>
1274
1275 ${
1276 hasToken
1277 ? `<div class="status-row">
1278 <span class="status-badge status-badge--${rawIdea.status || "pending"}">
1279 ${rawIdea.status === "processing" ? "\u23F3 processing" : rawIdea.status === "succeeded" ? "\u2713 placed by AI" : rawIdea.status === "stuck" ? "\u26A0 stuck" : rawIdea.status === "deleted" ? "deleted" : "pending"}
1280 </span>
1281 </div>`
1282 : ""
1283 }
1284
1285 <h1>${escapeHtml(fileName)}</h1>
1286
1287 ${
1288 fileDeleted
1289 ? ""
1290 : `<div class="action-bar">
1291 <a class="download" href="${fileUrl}" download>Download</a>
1292 <button id="copyUrlBtn" class="copy-url-btn">Share</button>
1293 </div>`
1294 }
1295
1296 <div class="media">
1297 ${fileDeleted ? `<p style="color:rgba(255,255,255,0.6); padding:40px 0;">File was deleted</p>` : mediaHtml}
1298 </div>
1299 </div>
1300 </div>`;
1301
1302 const js = `
1303 const copyUrlBtn = document.getElementById("copyUrlBtn");
1304
1305 copyUrlBtn.addEventListener("click", () => {
1306 const url = new URL(window.location.href);
1307 url.searchParams.delete('token');
1308 if (!url.searchParams.has('html')) {
1309 url.searchParams.set('html', '');
1310 }
1311 navigator.clipboard.writeText(url.toString()).then(() => {
1312 const originalText = copyUrlBtn.textContent;
1313 copyUrlBtn.textContent = "\u2714\uFE0F Copied!";
1314 setTimeout(() => (copyUrlBtn.textContent = originalText), 900);
1315 });
1316 });`;
1317
1318 return page({
1319 title: `${escapeHtml(fileName)}`,
1320 css,
1321 body: bodyHtml,
1322 js,
1323 });
1324}
1325
1// orchestrators/pipelines/rawIdea.js
2// Automates raw idea placement: chooseRoot -> delegate to treeOrchestrator -> record result.
3
4import log from "../../seed/log.js";
5import { DELETED } from "../../seed/protocol.js";
6import { OrchestratorRuntime, parseJsonSafe } from "../../seed/orchestrators/runtime.js";
7import { SESSION_TYPES, updateSessionMeta } from "../../seed/ws/sessionRegistry.js";
8import { setRootId, getClientForUser, LLM_PRIORITY } from "../../seed/llm/conversation.js";
9
10import { getOrchestrator } from "../../seed/orchestrators/registry.js";
11import {
12 buildDeepTreeSummary,
13} from "../../seed/tree/treeFetch.js";
14import { getExtension } from "../loader.js";
15import { logContribution } from "../../seed/tree/contributions.js";
16import RawIdea from "./model.js";
17import Node from "../../seed/models/node.js";
18
19import { nullSocket } from "../../seed/orchestrators/helpers.js";
20
21/**
22 * Extract the best targetNodeId from a tree result's step summaries.
23 */
24function extractTargetNodeId(treeResult) {
25 if (!treeResult) return null;
26 const summaries = treeResult.stepSummaries || [];
27 for (let i = summaries.length - 1; i >= 0; i--) {
28 const s = summaries[i];
29 if (s.target && !s.skipped && !s.failed) {
30 const nodeId = s.targetNodeId || null;
31 if (nodeId) return nodeId;
32 }
33 }
34 return treeResult.rootId || null;
35}
36
37// ─────────────────────────────────────────────────────────────────────────
38// MAIN ORCHESTRATOR
39// ─────────────────────────────────────────────────────────────────────────
40
41export async function orchestrateRawIdeaPlacement({
42 rawIdeaId,
43 userId,
44 username,
45 withResponse = false,
46 source = "orchestrator",
47}) {
48 // Load and validate raw idea before creating runtime
49 const rawIdea = await RawIdea.findById(rawIdeaId);
50 if (!rawIdea || rawIdea.userId === DELETED) {
51 log.warn("Raw Ideas", `Raw idea ${rawIdeaId} not found or already placed`);
52 return withResponse ? { success: false, reason: "Raw idea not found" } : undefined;
53 }
54 if (rawIdea.userId !== userId) {
55 log.warn("Raw Ideas", `Raw idea ${rawIdeaId} ownership mismatch`);
56 return withResponse ? { success: false, reason: "Not authorized" } : undefined;
57 }
58 if (rawIdea.status && rawIdea.status !== "pending") {
59 log.warn("Raw Ideas", `Raw idea ${rawIdeaId} already ${rawIdea.status}`);
60 return withResponse ? { success: false, reason: `Already ${rawIdea.status}` } : undefined;
61 }
62
63 // Determine session type
64 const sessionType = source === "background"
65 ? SESSION_TYPES.SCHEDULED_RAW_IDEA
66 : withResponse
67 ? SESSION_TYPES.RAW_IDEA_CHAT
68 : SESSION_TYPES.RAW_IDEA_ORCHESTRATE;
69
70 // Resolve LLM provider for the slot
71 let llmProvider;
72 try {
73 const clientInfo = await getClientForUser(userId, "rawIdea");
74 llmProvider = {
75 isCustom: clientInfo.isCustom,
76 model: clientInfo.model,
77 connectionId: clientInfo.connectionId || null,
78 };
79 } catch {
80 llmProvider = undefined;
81 }
82
83 const rt = new OrchestratorRuntime({
84 rootId: "pending", // will be set after root selection
85 userId,
86 username,
87 visitorId: `rawIdea:${rawIdeaId}`,
88 sessionType,
89 description: `Raw idea placement: ${rawIdeaId}`,
90 modeKeyForLlm: "tree:librarian", // fallback, chooseRoot is tool-less
91 source,
92 slot: "rawIdea",
93 llmPriority: source === "background" ? (LLM_PRIORITY?.BACKGROUND || 4) : undefined,
94 });
95
96 // Mark as processing
97 rawIdea.status = "processing";
98 rawIdea.aiSessionId = null; // will be set after init
99 await rawIdea.save();
100
101 await rt.init(rawIdea.content);
102 rawIdea.aiSessionId = rt.sessionId;
103 await rawIdea.save();
104
105 // Log contribution: AI started processing
106 await logContribution({
107 userId,
108 nodeId: DELETED,
109 wasAi: true,
110 sessionId: rt.sessionId,
111 action: "rawIdea",
112 nodeVersion: "0",
113 rawIdeaAction: { action: "aiStarted", rawIdeaId: rawIdeaId.toString() },
114 });
115
116
117 log.verbose("Raw Ideas", `Raw-idea orchestrator started for ${rawIdeaId} (session: ${rt.sessionId})`);
118
119 const markStuck = async (reason) => {
120 log.verbose("Raw Ideas", `Raw idea ${rawIdeaId} stuck: ${reason}`);
121 rawIdea.status = "stuck";
122 await rawIdea.save();
123 rt.setResult(reason, "rawIdea:stuck");
124 rt.trackStep("rawIdea:complete", {
125 input: reason,
126 output: { status: "stuck", reason },
127 });
128 logContribution({
129 userId,
130 nodeId: DELETED,
131 wasAi: true,
132 chatId: rt.mainChatId,
133 sessionId: rt.sessionId,
134 action: "rawIdea",
135 nodeVersion: "0",
136 rawIdeaAction: { action: "aiFailed", rawIdeaId: rawIdeaId.toString() },
137 }).catch((e) => log.error("Raw Ideas", `Failed to log aiFailed contribution:`, e.message));
138 };
139
140 try {
141 // PHASE 1: Choose best-fit root
142 const nav = getExtension("navigation")?.exports;
143 const roots = nav ? await nav.getUserRootsWithNames(userId) : [];
144 if (!roots || roots.length === 0) {
145 await markStuck("No trees available for this user");
146 return withResponse ? { success: false, reason: "No trees available for this user" } : undefined;
147 }
148
149 const rootSummaries = await Promise.all(
150 roots.map(async (r) => {
151 let encodingMap = null;
152 try {
153 const uExt = getExtension("understanding");
154 if (uExt?.exports?.getEncodingMap) encodingMap = await uExt.exports.getEncodingMap(r._id);
155 } catch {}
156 const summary = await buildDeepTreeSummary(r._id, { encodingMap }).catch(() => "(summary unavailable)");
157 return { rootId: r._id, name: r.name, summary };
158 }),
159 );
160
161 const { parsed, raw: chooseResult } = await rt.runStep("home:raw-idea-choose-root", {
162 prompt: rawIdea.content,
163 modeCtx: { content: rawIdea.content, rootSummaries },
164 input: rawIdea.content,
165 });
166
167 const chosenRootId = parsed?.rootId;
168 const confidence = typeof parsed?.confidence === "number" ? parsed.confidence : 0;
169
170 if (!chosenRootId || confidence < 0.35) {
171 const stuckReason = parsed?.reasoning || `No tree fit (confidence: ${confidence.toFixed(2)})`;
172 await markStuck(stuckReason);
173 return withResponse ? { success: false, reason: stuckReason } : undefined;
174 }
175
176 log.debug("Raw Ideas", `Chosen root: ${parsed.rootName} (${chosenRootId}) confidence=${confidence.toFixed(2)}`);
177
178 // PHASE 2: Delegate to tree orchestrator
179 setRootId(rt.visitorId, chosenRootId);
180 updateSessionMeta(rt.sessionId, { rootId: chosenRootId });
181
182 const treeOrch = getOrchestrator("tree");
183 if (!treeOrch) throw new Error("No tree orchestrator installed");
184 const treeResult = await treeOrch.handle({
185 visitorId: rt.visitorId,
186 message: rawIdea.content,
187 socket: nullSocket,
188 username,
189 userId,
190 signal: rt.signal,
191 sessionId: rt.sessionId,
192 rootId: chosenRootId,
193 skipRespond: !withResponse,
194 slot: "rawIdea",
195 rootChatId: rt.mainChatId,
196 sourceType: withResponse ? "raw-idea-chat" : "raw-idea-place",
197 sourceId: rawIdeaId.toString(),
198 });
199
200 // Deferred to short-term memory
201 if (treeResult?.deferred) {
202 rawIdea.status = "deferred";
203 rawIdea.aiSessionId = rt.sessionId;
204 await rawIdea.save();
205
206 rt.trackStep("rawIdea:deferred", {
207 input: rawIdea.content,
208 output: { status: "deferred", memoryItemId: treeResult.memoryItemId },
209 });
210
211 rt.setResult(
212 withResponse
213 ? treeResult.answer || "Noted, collecting more context before placing."
214 : "Deferred to short-term memory",
215 "rawIdea:deferred",
216 );
217
218 log.debug("Raw Ideas", `Raw idea deferred to short memory`);
219
220 if (withResponse) {
221 return {
222 success: true,
223 deferred: true,
224 answer: treeResult.answer || "Noted, collecting more context before placing.",
225 rootId: chosenRootId,
226 rootName: parsed.rootName,
227 };
228 }
229 return undefined;
230 }
231
232 if (!treeResult || treeResult.noFit || !treeResult.success) {
233 const stuckReason = treeResult?.reason
234 || (treeResult?.noFit ? "Tree rejected the idea (no_fit)" : "Tree orchestration failed");
235 await markStuck(stuckReason);
236 return withResponse ? { success: false, reason: stuckReason } : undefined;
237 }
238
239 // PHASE 3: Record successful placement
240 const targetNodeId = extractTargetNodeId(treeResult) || chosenRootId;
241
242 rawIdea.status = "succeeded";
243 rawIdea.placedAt = new Date();
244 await rawIdea.save();
245
246 const targetNode = await Node.findById(targetNodeId).select("metadata name parent").lean();
247 const pMeta = targetNode?.metadata instanceof Map ? targetNode.metadata.get("prestige") : targetNode?.metadata?.prestige;
248 const nodeVersion = String(pMeta?.current || 0);
249
250 // Build full path from root to target node
251 const targetNodePath = [];
252 let cursor = targetNode;
253 while (cursor) {
254 if (cursor.systemRole) break;
255 targetNodePath.unshift({ _id: cursor._id, name: cursor.name });
256 if (!cursor.parent || cursor.parent === DELETED) break;
257 cursor = await Node.findById(cursor.parent).select("_id name parent systemRole").lean();
258 }
259
260 await logContribution({
261 userId,
262 nodeId: targetNodeId,
263 wasAi: true,
264 chatId: rt.mainChatId,
265 sessionId: rt.sessionId,
266 action: "rawIdea",
267 nodeVersion,
268 rawIdeaAction: { action: "placed", rawIdeaId: rawIdeaId.toString(), targetNodeId },
269 });
270
271 rt.trackStep("rawIdea:complete", {
272 input: rawIdea.content,
273 output: { status: "succeeded", targetNodeId },
274 });
275
276 rt.setResult(
277 withResponse ? treeResult.answer || `Placed on node ${targetNodeId}` : `Placed on node ${targetNodeId}`,
278 "rawIdea:complete",
279 );
280
281 log.debug("Raw Ideas", `Raw idea placed on node ${targetNodeId}`);
282
283 if (withResponse) {
284 return {
285 success: true,
286 answer: treeResult.answer || null,
287 rootId: chosenRootId,
288 rootName: parsed.rootName || null,
289 targetNodeId,
290 targetNodePath,
291 };
292 }
293 } catch (err) {
294 log.error("Raw Ideas", `Raw-idea orchestration error for ${rawIdeaId}:`, err.message);
295 try {
296 rawIdea.status = "stuck";
297 await rawIdea.save();
298 rt.trackStep("rawIdea:complete", {
299 input: rawIdea.content,
300 output: { status: "stuck", reason: err.message },
301 });
302 logContribution({
303 userId,
304 nodeId: DELETED,
305 wasAi: true,
306 chatId: rt.mainChatId,
307 sessionId: rt.sessionId,
308 action: "rawIdea",
309 nodeVersion: "0",
310 rawIdeaAction: { action: "aiFailed", rawIdeaId: rawIdeaId.toString() },
311 }).catch((e) => log.error("Raw Ideas", `Failed to log aiFailed contribution:`, e.message));
312 } catch (saveErr) {
313 log.error("Raw Ideas", `Failed to mark raw idea as stuck:`, saveErr.message);
314 }
315 rt.setError(err.message, "rawIdea:complete");
316 if (withResponse) {
317 return { success: false, reason: err.message };
318 }
319 } finally {
320 await rt.cleanup();
321 }
322}
323
1import log from "../../seed/log.js";
2import express from "express";
3import { sendOk, sendError, ERR, DELETED } from "../../seed/protocol.js";
4import { getLandConfigValue } from "../../seed/landConfig.js";
5import mongoose from "mongoose";
6import path from "path";
7import fs from "fs";
8import { fileURLToPath } from "url";
9const __riDir = path.dirname(fileURLToPath(import.meta.url));
10import multer from "multer";
11import authenticate from "../../seed/middleware/authenticate.js";
12import preUploadCheck from "../../seed/middleware/preUploadCheck.js";
13import { getLandUrl } from "../../canopy/identity.js";
14import { userHasLlm } from "../../seed/llm/conversation.js";
15import { orchestrateRawIdeaPlacement } from "./pipeline.js";
16import RawIdea from "./model.js";
17import User from "../../seed/models/user.js";
18import {
19 createRawIdea as coreCreateRawIdea,
20 getRawIdeas as coreGetRawIdeas,
21 searchRawIdeasByUser as coreSearchRawIdeasByUser,
22 deleteRawIdeaAndFile as coreDeleteRawIdeaAndFile,
23 convertRawIdeaToNote as coreConvertRawIdeaToNote,
24 toggleAutoPlace as coreToggleAutoPlace,
25 AUTO_PLACE_ELIGIBLE,
26} from "./core.js";
27import { getExtension } from "../loader.js";
28let htmlAuth = authenticate;
29export function resolveHtmlAuth() {
30 const htmlExt = getExtension("html-rendering");
31 if (htmlExt?.exports?.urlAuth) htmlAuth = htmlExt.exports.urlAuth;
32}
33
34import { renderRawIdeasList, renderRawIdeaText, renderRawIdeaFile } from "./pages/rawIdeas.js";
35
36function notFoundPage(req, res, message = "This page doesn't exist or may have been moved.") {
37 const fn = getExtension("html-rendering")?.exports?.notFoundPage;
38 if (fn) return fn(req, res, message);
39 return sendError(res, 404, ERR.NOTE_NOT_FOUND, message);
40}
41
42const router = express.Router();
43
44const uploadsFolder = path.join(__riDir, "../../uploads");
45if (!fs.existsSync(uploadsFolder)) fs.mkdirSync(uploadsFolder);
46
47const storage = multer.diskStorage({
48 destination: (req, file, cb) => cb(null, uploadsFolder),
49 filename: (req, file, cb) => {
50 const ext = path.extname(file.originalname);
51 const name = Date.now() + "-" + Math.random().toString(36).slice(2);
52 cb(null, name + ext);
53 },
54});
55
56const upload = multer({
57 storage,
58 limits: { fileSize: Number(getLandConfigValue("maxUploadBytes")) || 104857600 },
59});
60
61function escapeHtml(str) {
62 return String(str || "")
63 .replace(/&/g, "&")
64 .replace(/</g, "<")
65 .replace(/>/g, ">")
66 .replace(/"/g, """);
67}
68
69// POST create raw idea
70router.post(
71 "/user/:userId/raw-ideas",
72 authenticate,
73 preUploadCheck,
74 upload.single("file"),
75 async (req, res) => {
76 try {
77 const { userId } = req.params;
78 if (req.userId.toString() !== userId.toString()) {
79 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
80 }
81
82 const contentType = req.file ? "file" : "text";
83 const result = await coreCreateRawIdea({
84 contentType,
85 content: contentType === "file" ? req.file.filename : req.body.content,
86 userId: req.userId,
87 file: req.file,
88 });
89
90 if ("html" in req.query) {
91 return res.redirect(
92 `/api/v1/user/${userId}?token=${req.query.token ?? ""}&html`,
93 );
94 }
95
96 return sendOk(res, { rawIdea: result.rawIdea }, 201);
97 } catch (err) {
98 sendError(res, 400, ERR.INVALID_INPUT, err.message);
99 }
100 },
101);
102
103// GET list raw ideas (requires auth: JWT or share token)
104router.get("/user/:userId/raw-ideas", htmlAuth, async (req, res) => {
105 try {
106 const userId = req.params.userId;
107 const wantHtml = Object.prototype.hasOwnProperty.call(req.query, "html");
108 const startDate = req.query.startDate;
109 const endDate = req.query.endDate;
110
111 const rawLimit = req.query.limit;
112 let limit = rawLimit !== undefined ? Number(rawLimit) : undefined;
113 if (limit >= 200 || limit == undefined) limit = 200;
114 if (limit !== undefined && (isNaN(limit) || limit <= 0)) {
115 return sendError(res, 400, ERR.INVALID_INPUT, "Invalid limit: must be a positive number");
116 }
117
118 const query = req.query.q || "";
119 const statusFilter = req.query.status || "pending";
120
121 let result;
122 if (query.trim() !== "") {
123 result = await coreSearchRawIdeasByUser({
124 userId, query, limit, startDate, endDate, status: statusFilter,
125 });
126 } else {
127 result = await coreGetRawIdeas({
128 userId, limit, startDate, endDate, status: statusFilter,
129 });
130 }
131
132 const rawIdeas = result.rawIdeas.map((r) => ({
133 ...r,
134 content: r.contentType === "file" ? `/api/v1/uploads/${r.content}` : r.content,
135 }));
136
137 if (!wantHtml || !getExtension("html-rendering")) {
138 return sendOk(res, { rawIdeas });
139 }
140
141 const user = await User.findById(userId).lean();
142 const token = req.query.token ?? "";
143
144 const tabUrl = (s) => {
145 const base = `/api/v1/user/${userId}/raw-ideas`;
146 const params = new URLSearchParams();
147 if (token) params.set("token", token);
148 params.set("html", "");
149 if (s !== "pending") params.set("status", s);
150 return `${base}?${params.toString()}`;
151 };
152 const tabs = [
153 { key: "pending", label: "Pending" },
154 { key: "processing", label: "Active" },
155 { key: "succeeded", label: "Finished" },
156 { key: "stuck", label: "Stuck" },
157 { key: "deferred", label: "Deferred" },
158 { key: "deleted", label: "Deleted" },
159 ];
160
161 return res.send(
162 renderRawIdeasList({
163 userId, user, rawIdeas, query, statusFilter, tabs, tabUrl, token, AUTO_PLACE_ELIGIBLE,
164 }),
165 );
166 } catch (err) {
167 log.error("Raw Ideas", "Error in /user/:userId/raw-ideas:", err);
168 sendError(res, 400, ERR.INVALID_INPUT, err.message);
169 }
170});
171
172// POST toggle auto-place
173router.post(
174 "/user/:userId/raw-ideas/auto-place",
175 authenticate,
176 async (req, res) => {
177 try {
178 if (req.userId.toString() !== req.params.userId.toString()) {
179 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
180 }
181 const enabled = req.body?.enabled;
182 if (typeof enabled !== "boolean") {
183 return sendError(res, 400, ERR.INVALID_INPUT, "enabled (boolean) is required");
184 }
185 const result = await coreToggleAutoPlace({ userId: req.userId, enabled });
186 return sendOk(res, { enabled: result.enabled });
187 } catch (err) {
188 const status = err.message.includes("only available on") ? 403 : 500;
189 const code = status === 403 ? ERR.FORBIDDEN : ERR.INTERNAL;
190 return sendError(res, status, code, err.message);
191 }
192 },
193);
194
195// DELETE raw idea
196router.delete(
197 "/user/:userId/raw-ideas/:rawIdeaId",
198 authenticate,
199 async (req, res) => {
200 try {
201 const { userId, rawIdeaId } = req.params;
202 if (req.userId.toString() !== userId.toString()) {
203 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
204 }
205 const rawIdea = await RawIdea.findById(rawIdeaId);
206 if (!rawIdea) {
207 return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Raw idea not found");
208 }
209 if (rawIdea.status === "processing" || rawIdea.status === "succeeded") {
210 return sendError(res, 409, ERR.RESOURCE_CONFLICT, `Cannot delete a raw idea with status "${rawIdea.status}"`);
211 }
212 const result = await coreDeleteRawIdeaAndFile({ rawIdeaId, userId: req.userId });
213 return sendOk(res, result);
214 } catch (err) {
215 sendError(res, 400, ERR.INVALID_INPUT, err.message);
216 }
217 },
218);
219
220// POST transfer raw idea to note
221router.post(
222 "/user/:userId/raw-ideas/:rawIdeaId/transfer",
223 authenticate,
224 async (req, res) => {
225 try {
226 const { userId, rawIdeaId } = req.params;
227 const { nodeId } = req.body;
228 if (req.userId.toString() !== userId.toString()) {
229 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
230 }
231 if (!rawIdeaId || !nodeId) {
232 return sendError(res, 400, ERR.INVALID_INPUT, "raw-idea Id and nodeId are required");
233 }
234 const rawIdeaCheck = await RawIdea.findById(rawIdeaId).lean();
235 if (rawIdeaCheck?.status === "processing") {
236 return sendError(res, 409, ERR.RESOURCE_CONFLICT, "Cannot transfer a raw idea while it is being processed");
237 }
238 const result = await coreConvertRawIdeaToNote({ rawIdeaId, userId: req.userId, nodeId });
239
240 if ("html" in req.query) {
241 return res.redirect(
242 `/api/v1/user/${userId}/raw-ideas?token=${req.query.token ?? ""}&html`,
243 );
244 }
245 return sendOk(res, { note: result.note });
246 } catch (err) {
247 log.error("Raw Ideas", "raw-idea transfer error:", err);
248 return sendError(res, 400, ERR.INVALID_INPUT, err.message);
249 }
250 },
251);
252
253// GET single raw idea
254router.get("/user/:userId/raw-ideas/:rawIdeaId", async (req, res) => {
255 try {
256 const { userId, rawIdeaId } = req.params;
257 const rawIdea = await RawIdea.findById(rawIdeaId)
258 .populate("userId", "username")
259 .lean();
260
261 if (!rawIdea) {
262 return notFoundPage(req, res, "This raw idea doesn't exist or may have been removed.");
263 }
264
265 const rawUserId = rawIdea.userId?._id?.toString?.() ?? rawIdea.userId;
266 if (["deleted", "empty", "null", "system"].includes(rawUserId)) {
267 return notFoundPage(req, res, "This raw idea doesn't exist or may have been removed.");
268 }
269 if (rawUserId !== userId.toString()) {
270 return notFoundPage(req, res, "This raw idea doesn't exist or may have been removed.");
271 }
272
273 const token = req.query.token ?? "";
274 const tokenQS = token ? `?token=${token}&html` : `?html`;
275 const hasToken = !!token;
276 const back = hasToken
277 ? `/api/v1/user/${userId}/raw-ideas${tokenQS}`
278 : getLandUrl();
279 const backText = hasToken ? "← Back to Raw Ideas" : "← Back to Home";
280 const userLink =
281 rawIdea.userId && rawIdea.userId !== "empty"
282 ? `<a href="/api/v1/user/${rawIdea.userId._id}${tokenQS}">
283 ${escapeHtml(rawIdea.userId.username ?? String(rawIdea.userId))}
284 </a>`
285 : "Unknown user";
286
287 if (req.query.html !== undefined && getExtension("html-rendering")) {
288 if (rawIdea.contentType === "text") {
289 return res.send(
290 renderRawIdeaText({ userId, rawIdea, back, backText, userLink, hasToken, token }),
291 );
292 }
293 return res.send(
294 renderRawIdeaFile({ userId, rawIdea, back, backText, userLink, hasToken, token }),
295 );
296 }
297
298 if (rawIdea.contentType === "text") {
299 return sendOk(res, { text: rawIdea.content });
300 }
301 if (rawIdea.contentType === "file") {
302 const filePath = path.join(uploadsFolder, rawIdea.content);
303 if (!fs.existsSync(filePath)) {
304 return sendError(res, 404, ERR.NOTE_NOT_FOUND, "File not found");
305 }
306 return res.sendFile(filePath);
307 }
308
309 sendError(res, 400, ERR.INVALID_TYPE, "Unknown raw idea type");
310 } catch (err) {
311 sendError(res, 500, ERR.INTERNAL, err.message);
312 }
313});
314
315// ─────────────────────────────────────────────────────────────────────────
316// Orchestration endpoints (moved from routes/api/orchestrate.js)
317// ─────────────────────────────────────────────────────────────────────────
318
319const TIMEOUT_MS = 19 * 60 * 1000;
320
321router.post("/user/:userId/raw-ideas/place", authenticate, async (req, res) => {
322 try {
323 if (req.userId.toString() !== req.params.userId.toString()) {
324 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
325 }
326 const content = req.body?.content;
327 if (!content || typeof content !== "string" || !content.trim()) {
328 return sendError(res, 400, ERR.INVALID_INPUT, "content (text string) is required");
329 }
330 if (!(await userHasLlm(req.userId))) {
331 return sendError(res, 503, ERR.LLM_NOT_CONFIGURED, "No LLM connection. Visit /setup to set one up.");
332 }
333 const alreadyProcessing = await RawIdea.findOne({ userId: req.userId.toString(), status: "processing" });
334 if (alreadyProcessing) {
335 return sendError(res, 409, ERR.ORCHESTRATOR_LOCKED, "Another idea is already being placed. Please wait for it to finish.");
336 }
337 const result = await coreCreateRawIdea({ contentType: "text", content: content.trim(), userId: req.userId });
338 const user = await User.findById(req.userId).select("username").lean();
339 const source = req.body?.source === "user" ? "user" : "api";
340 orchestrateRawIdeaPlacement({
341 rawIdeaId: result.rawIdea._id, userId: req.userId, username: user?.username || "unknown", source,
342 }).catch((err) => log.error("Raw Ideas", "Raw-idea orchestration failed:", err.message));
343 return sendOk(res, { message: "Orchestration started", rawIdeaId: result.rawIdea._id }, 202);
344 } catch (err) {
345 log.error("Raw Ideas", "raw-idea create+place error:", err);
346 return sendError(res, 500, ERR.INTERNAL, err.message);
347 }
348});
349
350router.post("/user/:userId/raw-ideas/chat", authenticate, async (req, res) => {
351 try {
352 if (req.userId.toString() !== req.params.userId.toString()) {
353 return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
354 }
355 const content = req.body?.content;
356 if (!content || typeof content !== "string" || !content.trim()) {
357 return sendError(res, 400, ERR.INVALID_INPUT, "content (text string) is required");
358 }
359 if (!(await userHasLlm(req.userId))) {
360 return sendError(res, 503, ERR.LLM_NOT_CONFIGURED, "No LLM connection. Visit /setup to set one up.");
361 }
362 const alreadyProcessing = await RawIdea.findOne({ userId: req.userId.toString(), status: "processing" });
363 if (alreadyProcessing) {
364 return sendError(res, 409, ERR.ORCHESTRATOR_LOCKED, "Another idea is already being placed. Please wait for it to finish.");
365 }
366 const result = await coreCreateRawIdea({ contentType: "text", content: content.trim(), userId: req.userId });
367 const user = await User.findById(req.userId).select("username").lean();
368 let timedOut = false;
369 const timer = setTimeout(() => { timedOut = true; if (!res.headersSent) sendError(res, 500, ERR.TIMEOUT, "Request timed out."); }, TIMEOUT_MS);
370 const source = req.body?.source === "user" ? "user" : "api";
371 const orchResult = await orchestrateRawIdeaPlacement({ rawIdeaId: result.rawIdea._id, userId: req.userId, username: user?.username || "unknown", withResponse: true, source });
372 clearTimeout(timer);
373 if (timedOut) return;
374 if (!orchResult || !orchResult.success) return sendError(res, 503, ERR.LLM_FAILED, orchResult?.reason || "Could not process the idea.");
375 return sendOk(res, { answer: orchResult.answer, rootId: orchResult.rootId, rootName: orchResult.rootName, targetNodeId: orchResult.targetNodeId, rawIdeaId: result.rawIdea._id });
376 } catch (err) {
377 log.error("Raw Ideas", "raw-idea create+chat error:", err);
378 return sendError(res, 500, ERR.INTERNAL, err.message);
379 }
380});
381
382router.post("/user/:userId/raw-ideas/:rawIdeaId/place", authenticate, async (req, res) => {
383 try {
384 const { rawIdeaId } = req.params;
385 if (req.userId.toString() !== req.params.userId.toString()) return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
386 const rawIdea = await RawIdea.findById(rawIdeaId);
387 if (!rawIdea || rawIdea.userId === DELETED) return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Raw idea not found");
388 if (rawIdea.userId.toString() !== req.userId.toString()) return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
389 if (rawIdea.contentType === "file") return sendError(res, 400, ERR.INVALID_TYPE, "File ideas cannot be auto-placed");
390 if (rawIdea.status && rawIdea.status !== "pending") return sendError(res, 409, ERR.RESOURCE_CONFLICT, `Already ${rawIdea.status}`);
391 if (!(await userHasLlm(req.userId))) return sendError(res, 503, ERR.LLM_NOT_CONFIGURED, "No LLM connection. Visit /setup to set one up.");
392 const alreadyProcessing = await RawIdea.findOne({ userId: req.userId.toString(), status: "processing" });
393 if (alreadyProcessing) return sendError(res, 409, ERR.ORCHESTRATOR_LOCKED, "Another idea is already being placed. Please wait for it to finish.");
394 const user = await User.findById(req.userId).select("username").lean();
395 const source = req.body?.source === "user" ? "user" : "api";
396 orchestrateRawIdeaPlacement({ rawIdeaId, userId: req.userId, username: user?.username || "unknown", source }).catch((err) => log.error("Raw Ideas", "Raw-idea orchestration failed:", err.message));
397 return sendOk(res, { message: "Orchestration started" }, 202);
398 } catch (err) {
399 log.error("Raw Ideas", "raw-idea orchestrate error:", err);
400 return sendError(res, 500, ERR.INTERNAL, err.message);
401 }
402});
403
404router.post("/user/:userId/raw-ideas/:rawIdeaId/chat", authenticate, async (req, res) => {
405 try {
406 const { rawIdeaId } = req.params;
407 if (req.userId.toString() !== req.params.userId.toString()) return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
408 const rawIdea = await RawIdea.findById(rawIdeaId);
409 if (!rawIdea || rawIdea.userId === DELETED) return sendError(res, 404, ERR.NOTE_NOT_FOUND, "Raw idea not found");
410 if (rawIdea.userId.toString() !== req.userId.toString()) return sendError(res, 403, ERR.FORBIDDEN, "Not authorized");
411 if (rawIdea.contentType === "file") return sendError(res, 400, ERR.INVALID_TYPE, "File ideas cannot be auto-placed");
412 if (rawIdea.status && rawIdea.status !== "pending") return sendError(res, 409, ERR.RESOURCE_CONFLICT, `Already ${rawIdea.status}`);
413 if (!(await userHasLlm(req.userId))) return sendError(res, 503, ERR.LLM_NOT_CONFIGURED, "No LLM connection. Visit /setup to set one up.");
414 const alreadyProcessing = await RawIdea.findOne({ userId: req.userId.toString(), status: "processing" });
415 if (alreadyProcessing) return sendError(res, 409, ERR.ORCHESTRATOR_LOCKED, "Another idea is already being placed. Please wait for it to finish.");
416 const user = await User.findById(req.userId).select("username").lean();
417 let timedOut = false;
418 const timer = setTimeout(() => { timedOut = true; if (!res.headersSent) sendError(res, 500, ERR.TIMEOUT, "Request timed out."); }, TIMEOUT_MS);
419 const source = req.body?.source === "user" ? "user" : "api";
420 const result = await orchestrateRawIdeaPlacement({ rawIdeaId, userId: req.userId, username: user?.username || "unknown", withResponse: true, source });
421 clearTimeout(timer);
422 if (timedOut) return;
423 if (!result || !result.success) return sendError(res, 503, ERR.LLM_FAILED, result?.reason || "Could not process the idea.");
424 return sendOk(res, { answer: result.answer, rootId: result.rootId, rootName: result.rootName, targetNodeId: result.targetNodeId });
425 } catch (err) {
426 log.error("Raw Ideas", "raw-idea chat error:", err);
427 return sendError(res, 500, ERR.INTERNAL, err.message);
428 }
429});
430
431export default router;
432
1import { z } from "zod";
2import { getRawIdeas, convertRawIdeaToNote } from "./core.js";
3
4const TimeWindowSchema = {
5 startDate: z
6 .string()
7 .optional()
8 .describe("ISO date/time. Include items created on or after this time."),
9 endDate: z
10 .string()
11 .optional()
12 .describe("ISO date/time. Include items created on or before this time."),
13};
14
15export default [
16 {
17 name: "raw-idea-filter-orchestrator",
18 description: "Guides filtering and placing raw ideas into the tree. READ-ONLY.",
19 schema: {
20 userId: z
21 .string()
22 .describe("The user whose raw ideas will be processed."),
23 },
24 annotations: {
25 readOnlyHint: true,
26 destructiveHint: false,
27 idempotentHint: true,
28 openWorldHint: false,
29 },
30 handler: async ({}) => {
31 const instructions = `
32You are entering **Raw Idea Filtering Mode**.
33
34GOAL
35Help the user take an unplaced raw idea and decide the *best hierarchical location* for it in their tree.
36You must NEVER convert a raw idea automatically. Always wait for confirmation.
37
38STEP-BY-STEP PROCESS
39
401️⃣ **Load Raw Ideas**
41- Call get-raw-ideas-by-user(userId)
42- Present a short list (titles or summaries).
43- Ask the user which raw idea they want to process.
44- If only one exists, you may auto-select it.
45
462️⃣ **Load User Roots**
47- Call get-root-nodes-by-user(userId)
48- If multiple roots exist:
49 - Choose the most relevant root based on the raw idea
50 - Ask the user to confirm or override
51
523️⃣ **Inspect Tree Structure**
53- Call get-tree(rootId)
54- Analyze where the raw idea logically belongs:
55
564️⃣ **Determine Best Placement**
57- Decide:
58 - Target node ID
59- Explain *why* this location fits:
60 - Purpose
61 - Scope
62 - Hierarchical logic
63
645️⃣ **Present Placement Proposal**
65- Clearly state:
66 - Raw idea summary
67 - Target node name
68- Ask the user explicitly:
69
70 "Would you like me to convert this raw idea into a note under <Node Name>?"
71
726️⃣ **Wait for Confirmation**
73- DO NOT call transfer-raw-idea-to-note yet.
74- Only proceed if the user explicitly agrees.
75- If confirmed:
76 → call transfer-raw-idea-to-note(rawIdeaId, userId, nodeId)
77
78RULES
79- Never guess silently.
80- Never place without consent.
81- Never skip tree inspection.
82- Prefer explaining structure over speed.
83`;
84
85 return {
86 content: [{ type: "text", text: instructions }],
87 };
88 },
89 },
90 {
91 name: "get-raw-ideas-by-user",
92 description: "Fetches raw ideas (inbox) for a user. Read-only.",
93 schema: {
94 userId: z.string().describe("Injected by server. Ignore."),
95 chatId: z
96 .string()
97 .nullable()
98 .optional()
99 .describe("Injected by server. Ignore."),
100 sessionId: z
101 .string()
102 .nullable()
103 .optional()
104 .describe("Injected by server. Ignore."),
105 limit: z
106 .number()
107 .optional()
108 .describe("Optional limit for number of raw ideas by most recent."),
109 ...TimeWindowSchema,
110 },
111 annotations: {
112 readOnlyHint: true,
113 destructiveHint: false,
114 idempotentHint: true,
115 openWorldHint: false,
116 },
117 handler: async ({ userId, limit, startDate, endDate }) => {
118 try {
119 if (typeof limit === "number" && limit > 30) {
120 limit = 30;
121 }
122
123 const result = await getRawIdeas({
124 userId,
125 limit,
126 startDate,
127 endDate,
128 });
129
130 return {
131 content: [
132 {
133 type: "text",
134 text: JSON.stringify(result.rawIdeas, null, 2),
135 },
136 ],
137 };
138 } catch (err) {
139 return {
140 content: [
141 {
142 type: "text",
143 text: `Failed to fetch raw ideas: ${err.message}`,
144 },
145 ],
146 isError: true,
147 };
148 }
149 },
150 },
151 {
152 name: "transfer-raw-idea-to-note",
153 description: "Converts a raw idea into a note on a specific node/version.",
154 schema: {
155 rawIdeaId: z.string().describe("ID of the raw idea to place."),
156 userId: z.string().describe("Injected by server. Ignore."),
157 chatId: z
158 .string()
159 .nullable()
160 .optional()
161 .describe("Injected by server. Ignore."),
162 sessionId: z
163 .string()
164 .nullable()
165 .optional()
166 .describe("Injected by server. Ignore."),
167 nodeId: z.string().describe("Target node ID."),
168 },
169 annotations: {
170 readOnlyHint: false,
171 destructiveHint: true,
172 idempotentHint: false,
173 openWorldHint: false,
174 },
175 handler: async ({ rawIdeaId, userId, nodeId, chatId, sessionId }) => {
176 try {
177 const result = await convertRawIdeaToNote({
178 rawIdeaId,
179 userId,
180 nodeId,
181 wasAi: true,
182 chatId,
183 sessionId,
184 });
185
186 return {
187 content: [
188 {
189 type: "text",
190 text: JSON.stringify(result, null, 2),
191 },
192 ],
193 };
194 } catch (err) {
195 return {
196 content: [
197 {
198 type: "text",
199 text: `Failed to place raw idea: ${err.message}`,
200 },
201 ],
202 isError: true,
203 };
204 }
205 },
206 },
207];
208
Loading comments...