EXTENSION for TreeOS
raw-ideas
Thoughts do not arrive organized. They arrive in the shower, on a walk, in the middle of something else. The raw ideas inbox is a capture layer that sits outside the tree. Text or file, no structure required. Drop it in and keep moving. The idea sits in your inbox with status pending until you or the AI decides where it belongs. Placement is a multi-phase AI orchestration pipeline. Phase one: the LLM reads the raw idea content alongside deep summaries of every tree the user owns. It picks the best-fit tree with a confidence score. Below 0.35 confidence the idea is marked stuck because forcing bad placement is worse than waiting. Phase two: the selected tree's orchestrator takes over. It navigates the tree structure, finds the right branch and node, creates child nodes if needed, and writes the idea as a note at the correct position. Phase three: the raw idea is marked succeeded with a timestamp and the full path from root to target node is recorded in the contribution log. Three entry points for placement. Manual: the user picks a node and transfers the idea themselves via the CLI or API. Interactive: the place endpoint runs the full pipeline and returns a conversational response explaining where it landed and why. Background: the auto-place job runs every 15 minutes, picks up the latest pending text idea for each eligible user who is offline, and fires the pipeline silently. Users toggle auto-place on or off. The job skips users who are currently online because they can trigger it themselves. Ideas can be deferred to short-term memory when the tree orchestrator determines the idea needs more context before placement. Status lifecycle: pending, processing, succeeded, stuck, deferred, deleted. @mentions in text ideas resolve to real users. File ideas support upload with storage tracking. Search supports exact phrase matching, word boundary matching, and hyphenated term matching across the inbox.
v1.0.1 by TreeOS Site 0 downloads 11 files 3,286 lines 100.7 KB published 38d ago
treeos ext install raw-ideas
View changelog

Manifest

Provides

  • 1 models
  • routes
  • tools
  • jobs
  • orchestrator
  • 7 CLI commands
  • 1 energy actions

Requires

  • services: llm, session, chat, orchestrator, contributions, hooks
  • models: Node, User, Note

Optional

  • services: energy
  • extensions: html-rendering, treeos-base
SHA256: b7023dfe82905c400f91704809886642e2eb596015ca0fedbe339091a5018ebe

CLI Commands

CommandMethodDescription
ideasGETList raw ideas (-p pending, -a all, -q search)
idea <message...>POSTAI places idea in the right tree
idea-store <message...>POSTSave idea without processing
idea-place <rawIdeaId>POSTProcess a stored idea
idea-transfer <rawIdeaId> <nodeId>POSTManually transfer idea to a node
idea-auto <toggle>POSTToggle auto-placement (on/off)
rm-idea <id>DELETEDelete a raw idea

Source Code

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, "&quot;")}"
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, "&amp;")
64    .replace(/</g, "&lt;")
65    .replace(/>/g, "&gt;")
66    .replace(/"/g, "&quot;");
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

Versions

Version Published Downloads
1.0.1 38d ago 0
1.0.0 48d ago 0
0 stars
0 flags
React from the CLI: treeos ext star raw-ideas

Comments

Loading comments...

Post comments from the CLI: treeos ext comment raw-ideas "your comment"
Max 3 comments per extension. One star and one flag per user.