Action

Query Drafts with Claude

Posted by h6y3, Last update 17 days ago

UPDATES

17 days ago

  • Fixed: Due to Anthropic deprecating support for temperature, this was returning no results.

Name: AI Note Query

Description:
Ask a question in plain language and let Claude search your notes to answer it. Uses a two-pass approach: first, note summaries are sent to Claude to identify relevant drafts; then the full content of those drafts is used to generate a grounded answer. Results are saved as a new draft or appended to your current one, with wiki-style [[links]] back to source notes.

Setup:
Requires the Anthropic AI credentials configured in Drafts (Settings → Credentials → Anthropic).

Usage:
1. Run the action from any draft
2. Type your question in plain language
3. Select your preferred Claude model
4. Choose whether to create a new draft or append to the current one
5. The answer appears with a timestamp, model name, and links to source notes

Caveats & Privacy:
- Note previews are sent to the Anthropic API. During the filtering pass, the first line and a 150-character preview of every draft in your collection is transmitted to Claude to determine relevance. Your full Drafts library size and note titles are included in this request.
- Relevant note content is sent in full. Notes identified as relevant are sent to the API in their entirety during the answer pass.
- This applies to all drafts in your library, including archived drafts. Review your Anthropic data retention settings if this is a concern.
- The script does not store or transmit credentials — API authentication is handled by Drafts’ built-in Anthropic integration.

Steps

  • script

    // AI-Powered Note Query for Drafts
    // Two-pass approach: Filter relevant notes, then answer with context.
    //
    // FIX (2026-07): Newer Claude models (claude-sonnet-5, claude-opus-4-8, etc.)
    // REJECT the `temperature` parameter — the messages endpoint returns
    // invalid_request_error: "`temperature` is deprecated for this model."
    // The old script passed {temperature: 0} on the filter call, so on these
    // models quickPrompt returned undefined, the filter caught it and returned [],
    // and every answer fell through to the ungrounded "general knowledge" path.
    // Root cause was a model API change, NOT the Drafts corpus/query.
    //
    // Changes from original:
    //   1. Removed the `temperature` option from the filter call.
    //   2. If the filter call fails, surface ai.lastError (don't silently go
    //      ungrounded), so any future rejected parameter is obvious immediately.
    //   3. Tolerant index extraction so a stray bit of prose can't zero the pass.
    
    const models = AnthropicAI.knownModels();
    
    // === HELPER FUNCTIONS ===
    
    function generateDraftSummaries(drafts) {
      return drafts.map((d, index) => {
        const firstLine = d.content.split('\n')[0] || "(Untitled)";
        const preview = d.content.substring(0, 150).replace(/\n/g, ' ');
        const tags = d.tags.length > 0 ? ` [Tags: ${d.tags.join(', ')}]` : '';
        return {
          index: index,
          uuid: d.uuid,
          summary: `[${index}] ${firstLine}\n${preview}${tags}`
        };
      });
    }
    
    // Pull a JSON array of integers out of the model's response, tolerating
    // stray prose or code fences around the array.
    function extractIndexArray(raw, maxIndex) {
      if (raw === undefined || raw === null) return null; // signal: no response
      let cleaned = String(raw).trim()
        .replace(/```json\n?/g, '')
        .replace(/```\n?/g, '');
      // Try a straight parse first (fast path, matches original behavior).
      try {
        const arr = JSON.parse(cleaned);
        if (Array.isArray(arr)) return arr.filter(n => Number.isInteger(n) && n >= 0 && n <= maxIndex);
      } catch (e) { /* fall through to bracket extraction */ }
      // Fallback: grab the first [ ... ] span, greedily to the LAST bracket.
      const start = cleaned.indexOf('[');
      const end = cleaned.lastIndexOf(']');
      if (start !== -1 && end > start) {
        try {
          const arr = JSON.parse(cleaned.substring(start, end + 1));
          if (Array.isArray(arr)) return arr.filter(n => Number.isInteger(n) && n >= 0 && n <= maxIndex);
        } catch (e) { /* give up */ }
      }
      return []; // response existed but no parseable array -> treat as "none relevant"
    }
    
    // Returns { indices: number[], error: string|null }
    function filterRelevantDrafts(ai, model, question, summaries) {
      const summaryText = summaries.map(s => s.summary).join('\n\n');
    
      const filterPrompt = `You are helping filter notes for relevance to a user's question.
    
    USER'S QUESTION: "${question}"
    
    AVAILABLE NOTES:
    ${summaryText}
    
    Task: Analyze which notes are relevant to answering the user's question. Return a JSON array of note indices that are relevant. Be generous in your selection - include notes that might contain useful context or related information.
    
    IMPORTANT:
    - Return ONLY a valid JSON array of numbers, nothing else
    - If no notes are relevant, return an empty array: []
    - Example valid responses: [0, 3, 7] or [] or [1, 2, 3, 4, 5]
    
    Response:`;
    
      // NOTE: no `temperature` option — newer models reject it.
      const response = ai.quickPrompt(filterPrompt, {"model": model});
    
      if (response === undefined || response === null) {
        // The call failed at the API level. Surface the real reason.
        const err = ai.lastError ? JSON.stringify(ai.lastError) : "unknown error (no lastError)";
        console.log("Filter call failed: " + err);
        return { indices: [], error: err };
      }
    
      const indices = extractIndexArray(response, summaries.length - 1);
      return { indices: indices || [], error: null };
    }
    
    function answerWithContext(ai, model, question, relevantDrafts) {
      let prompt;
    
      if (relevantDrafts.length === 0) {
        prompt = `The user asked: "${question}"
    
    No relevant notes were found in their collection.
    
    Please answer this question using your general knowledge. Start your response by saying "No relevant notes found in your collection." and then provide a helpful answer.`;
      } else {
        const contextText = relevantDrafts.map((d, i) => {
          const title = d.content.split('\n')[0] || "(Untitled)";
          return `--- Note ${i + 1}: ${title} ---\n${d.content}`;
        }).join('\n\n');
    
        prompt = `You are answering a question using the user's personal notes as context.
    
    USER'S QUESTION: "${question}"
    
    RELEVANT NOTES FROM USER'S COLLECTION:
    ${contextText}
    
    Task: Answer the user's question based primarily on the information in their notes. If the notes don't fully answer the question, you may augment with your general knowledge, but clearly distinguish between what's from their notes vs. your knowledge.
    
    Be conversational and helpful. Do not explicitly list which notes you're referencing unless it adds value to the answer.
    
    Answer:`;
      }
    
      // NOTE: no `temperature` option here either.
      return ai.quickPrompt(prompt, {"model": model});
    }
    
    // === MAIN SCRIPT ===
    
    let f = () => {
      let p = new Prompt();
      p.title = "AI Note Query";
      p.message = "Ask a question about your notes";
    
      p.addTextView("question", "Your Question", "");
      p.addLabel("label1", "Claude will search your notes and answer your question");
      p.addSelect("model", "Model", models, [models[0]], false);
      p.addSelect("output", "Output",
        ["New Draft", "Append to Current"],
        ["New Draft"],
        false
      );
      p.addButton("Search & Answer");
    
      if (!p.show()) {
        return false;
      }
    
      const question = p.fieldValues["question"];
      const selectedModel = p.fieldValues["model"][0];
      const outputMode = p.fieldValues["output"][0];
    
      if (!question || question.trim().length === 0) {
        alert("Please enter a question.");
        return false;
      }
    
      // Get all drafts (inbox + archive)
      const allDrafts = Draft.query("", "all", [], [], "modified", false, false);
      console.log(`Found ${allDrafts.length} total drafts`);
    
      const summaries = generateDraftSummaries(allDrafts);
      const ai = new AnthropicAI();
    
      // PASS 1: Filter relevant drafts
      console.log("Pass 1: Filtering relevant drafts...");
      const filterResult = filterRelevantDrafts(ai, selectedModel, question, summaries);
    
      // If the filter call errored at the API level, tell the user instead of
      // silently answering from general knowledge (which looks like success but isn't).
      if (filterResult.error) {
        alert("Filter step failed — answer would NOT be grounded in your notes.\n\n" +
              "Model: " + selectedModel + "\n\nError: " + filterResult.error);
        // Continue anyway so the user still gets an answer, clearly marked ungrounded.
      }
    
      const relevantIndices = filterResult.indices;
      console.log(`Found ${relevantIndices.length} relevant drafts`);
    
      const relevantDrafts = relevantIndices
        .map(idx => allDrafts[idx])
        .filter(d => d !== undefined);
    
      // PASS 2: Answer the question
      console.log("Pass 2: Generating answer...");
      const answer = answerWithContext(ai, selectedModel, question, relevantDrafts);
    
      if (!answer || answer.length === 0) {
        alert("No response from Claude. ai.lastError = " +
              (ai.lastError ? JSON.stringify(ai.lastError) : "none"));
        return false;
      }
    
      const timestamp = new Date().toLocaleString();
    
      let sourcesSection = "";
      if (relevantDrafts.length > 0) {
        const sourceLinks = relevantDrafts.map(d => {
          const title = d.content.split('\n')[0].replace(/^#+\s*/, '') || "(Untitled)";
          return `- [[${title}]]`;
        }).join('\n');
        sourcesSection = `\n\n## Sources\n\n${sourceLinks}`;
      }
    
      const result = `# Q: ${question}
    
    **Model:** ${selectedModel}
    **Date:** ${timestamp}
    **Notes Found:** ${relevantIndices.length}
    **Grounded:** ${relevantDrafts.length > 0 ? "YES" : "NO"}
    
    ---
    
    ${answer}${sourcesSection}`;
    
      if (outputMode === "New Draft") {
        const newDraft = Draft.create();
        newDraft.content = result;
        newDraft.addTag("project/temporary/ai-query");
        newDraft.update();
        editor.load(newDraft);
      } else {
        draft.content = draft.content + "\n\n" + result;
        draft.update();
      }
    
      app.displaySuccessMessage("Query complete!");
      return true;
    };
    
    if (!f()) {
      context.fail();
    }

Options

  • After Success Default
    Notification Info
    Log Level Info
Items available in the Drafts Directory are uploaded by community members. Use appropriate caution reviewing downloaded items before use.