Compare commits

..

No commits in common. "main" and "0.0.28.2" have entirely different histories.

1926 changed files with 52119 additions and 91932 deletions

View file

@ -1,36 +0,0 @@
---
description: Ponytail, lazy senior dev mode. Always pick the simplest solution that works.
globs:
alwaysApply: true
---
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.

View file

@ -1,173 +0,0 @@
---
name: animation-vocabulary
description: Reverse-lookup glossary that turns a vague description of a web animation or motion effect into its exact term ("the bouncy thing when a popover opens" → Pop in; "the iOS rubber-band scroll" → Rubber-banding). Use when the user asks "what's it called when…", or describes a motion effect without knowing its name and wants the right word to prompt an AI or designer with. For naming an effect, not designing or building one.
---
# Animation Vocabulary
Turn a vague description of a motion or effect into the precise term, so the user knows what to ask for.
## Quick Start
The user describes an effect loosely. You return the matching term(s) in this format:
```
**Stagger** — Animate several items one after another with a small delay between each, creating a cascade.
```
If several terms could fit, list the best match first, then 12 alternates with a one-line note on how they differ.
## Instructions
1. **Read for intent, not keywords.** Users describe what they *see* or *feel* ("springy", "slides off", "draws itself in"), not the technical name. Map the sensation to the glossary below.
2. **Quote the glossary verbatim.** Its descriptions are authoritative — use them as-is, don't paraphrase.
3. **Disambiguate close terms.** When two compete (*Clip-path* vs *Mask*, *Pop in* vs *Bounce*, *Shared element transition* vs *Layout animation*), contrast them so the user can pick.
4. **When nothing matches exactly,** name the closest term and say plainly it's an approximation, or describe the effect in the glossary's vocabulary (e.g. "that's a *stagger* of *scale-in* entrances").
5. **Stay within this glossary.** If a term genuinely isn't here, say so rather than inventing one — though you may explain the concept using these words.
6. **Keep it tight.** A naming question wants a name, not an essay. Lead with the term; expand only if asked.
## Examples
**Example 1 — feel-based**
User: "What's it called when a popover seems to grow out of the button you clicked instead of from its middle?"
Output:
```
**Origin-aware animation** — An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center which is the default in CSS.
```
**Example 2 — disambiguation**
User: "The thing where one image turns into another image."
Output:
```
**Morph** — One shape smoothly turns into another shape, e.g. Dynamic Island.
Close alternates:
- **Crossfade** — if they simply fade over each other in the same spot.
- **Shared element transition** — if an element travels and transforms from one position into another.
```
**Example 3 — physics feel**
User: "That iOS scroll where it resists and snaps back when you pull too far."
Output:
```
**Rubber-banding** — Resistance and snap-back when you drag past a boundary (the iOS overscroll feel).
```
## Glossary
_A curated snapshot mirroring the project's `/vocabulary` page; keep the two in sync when either changes._
### Entrances & Exits — how elements appear and disappear
- **Fade in / Fade out** — Element appears or disappears by changing opacity.
- **Slide in** — Element enters by sliding in from off-screen (left, right, top, or bottom).
- **Scale in** — Element grows from smaller to full size as it appears, often paired with a fade.
- **Pop in** — Element appears with a slight overshoot, like it bounces into place.
- **Reveal** — Content is uncovered gradually, often by animating a clip-path or mask.
- **Enter / Exit** — The animation an element plays when it's added to or removed from the screen.
### Sequencing & Timing — coordinating multiple elements or moments
- **Keyframes** — Defined points in an animation (0%, 50%, 100%) that the browser fills the gaps between.
- **Interpolation / Tween** — Generating all the in-between frames between a start and end value, so motion is continuous.
- **Stagger** — Animate several items one after another with a small delay between each, creating a cascade.
- **Orchestration** — Deliberately timing multiple animations so they feel like one coordinated motion.
- **Delay** — Time before an animation starts.
- **Duration** — How long an animation takes.
- **Fill mode** — Whether an element keeps its first or last frame's styles before the animation starts or after it ends (e.g. forwards).
- **Stepped animation** — An animation that is divided into discrete steps, like a countdown timer.
### Movement & Transforms — changing an element's position, size, or angle
- **Translate** — Move an element along the X or Y axis.
- **Scale** — Make an element bigger or smaller.
- **Rotate** — Spin an element around a point.
- **Skew** — Slant an element along the X or Y axis, shearing it out of its rectangular shape.
- **3D tilt / Flip** — Rotate in 3D space (rotateX / rotateY) to add depth.
- **Perspective** — How strong the 3D effect looks — a lower value exaggerates depth, like the viewer is closer.
- **Transform origin** — The anchor point a scale or rotation grows or spins from.
- **Origin-aware animation** — An element animates out of its trigger, like a popover growing from the button that opened it instead of from its own center which is the default in CSS.
### Transitions Between States — connecting one state, view, or element to another
- **Crossfade** — One element fades out as another fades in, in the same spot.
- **Continuity transition** — A change that keeps the user oriented by visually connecting before and after. For example, making the same rectangle bigger and smaller.
- **Morph** — One shape smoothly turns into another shape, e.g. Dynamic Island.
- **Shared element transition** — An element travels and transforms from one position into another, like a thumbnail expanding into a card.
- **Layout animation** — When an element's size or position changes, it animates to the new spot instead of snapping.
- **Accordion / Collapse** — A section smoothly expands and collapses its height to show or hide content.
- **Direction-aware transition** — Content slides one way going forward and the opposite way going back, so navigation has a sense of direction.
### Scroll — motion tied to scrolling or navigating between views
- **Scroll reveal** — Elements fade or slide into place as they enter the viewport.
- **Scroll-driven animation** — An animation whose progress is tied directly to scroll position.
- **Parallax** — Background and foreground move at different speeds while scrolling, creating depth.
- **Page transition** — An animation that plays when navigating from one page or route to another.
- **View transition** — The browser morphs between two states or pages, connecting shared elements.
### Feedback & Interaction — responding to the user's actions
- **Hover effect** — Visual change when the cursor moves over an element.
- **Press / Tap feedback** — A subtle scale-down when an element is clicked, so it feels physical.
- **Hold to confirm** — A progress effect that fills up while the user holds a button.
- **Drag** — Moving an element by grabbing it, often with momentum when released.
- **Drag to reorder** — Dragging items in a list to rearrange them, while the others shift to make room.
- **Swipe to dismiss** — Dragging an element off-screen to close it, like a drawer or toast.
- **Rubber-banding** — Resistance and snap-back when you drag past a boundary (the iOS overscroll feel).
- **Shake / Wiggle** — A quick side-to-side jitter signaling an error or rejected input.
- **Ripple** — A circle expanding from the point of a tap, confirming the press.
### Easing — how speed changes over an animation
- **Easing** — The rate at which an animation speeds up or slows down.
- **Ease-out** — Starts fast, ends slow. The default for most UI and anything responding to the user.
- **Ease-in** — Starts slow, ends fast. Usually avoided; can feel sluggish.
- **Ease-in-out** — Slow, fast, slow. Good for elements already on screen moving from A to B.
- **Linear** — Constant speed. Avoid for UI; reserve for spinners or marquees.
- **Cubic-bezier** — A custom easing curve you define for precise control.
- **Asymmetric easing** — A curve that accelerates and decelerates at different rates. Feels more alive than a symmetric one.
### Spring Animations — physics-based motion as an alternative to fixed-duration easing
- **Spring** — Motion driven by physics (tension, mass, damping) rather than a set duration.
- **Stiffness / Tension** — How strongly the spring pulls toward its target. Higher feels snappier.
- **Damping** — How quickly a spring settles. Lower damping means more bounce and oscillation.
- **Mass** — How heavy the animated element feels. More mass makes it slower and more sluggish.
- **Bounce** — A spring that overshoots and settles, adding playfulness.
- **Perceptual duration** — How long a spring feels finished, even though it keeps micro-settling underneath.
- **Momentum** — Motion that carries velocity, especially after a drag or interruption.
- **Velocity** — How fast and in which direction an element is moving. A spring carries it into the next animation when interrupted, so a flicked element keeps its speed.
- **Interruptible animation** — An animation that can be smoothly redirected mid-flight instead of finishing first.
### Looping & Ambient Motion — animations that run on their own
- **Marquee** — Text or content that scrolls continuously in a loop.
- **Loop** — An animation that repeats, a set number of times or infinitely.
- **Alternate (yoyo)** — A loop that plays forward then reverses each iteration, instead of jumping back to the start.
- **Orbit** — An element circling around another in a continuous path.
- **Pulse** — A gentle repeating scale or opacity change to draw attention.
- **Float** — A gentle, continuous up-and-down drift that makes a static element feel alive and weightless.
- **Idle animation** — Subtle motion that plays while an element is just sitting there, waiting to be interacted with.
### Polish & Effects — the small touches that separate good from great
- **Blur** — A blur filter used to soften an element or mask tiny imperfections.
- **Clip-path** — Clipping an element to a shape, used for reveals, masks, and before/after sliders.
- **Mask** — Hiding or revealing parts of an element using a shape or gradient — like clip-path, but with soft, fadeable edges.
- **Before / after slider** — A draggable divider that wipes between two overlaid images to compare them.
- **Line drawing** — An SVG path that draws itself in, like an invisible pen tracing it.
- **Text morph** — Text that animates character by character when it changes, drawing attention to the new value.
- **Skeleton / Shimmer** — A placeholder with a moving sheen shown while content loads.
- **Number ticker** — Digits rolling or counting up to a value.
- **Tabular numbers** — Fixed-width digits so numbers don't shift around as they change. Essential for tickers, timers, and counters.
- **Typewriter** — Text appearing one character at a time, as if being typed.
### Performance — what keeps motion smooth instead of stuttering
- **Frame rate (FPS)** — Frames drawn per second. 60fps is the baseline for smooth motion; 120fps on newer displays.
- **Jank** — Visible stutter when the browser drops frames because it can't keep up with the animation.
- **Dropped frame** — A frame the browser missed its deadline to draw, causing a tiny hitch in motion.
- **Compositing** — Letting the GPU move or fade an element on its own layer without redoing layout or paint.
- **will-change** — A CSS hint that an element is about to animate, so the browser can promote it to its own layer ahead of time.
- **Layout thrashing** — Animating properties like width, height, top, or left that force the browser to recalculate layout every frame, causing jank.
### Principles to Know — concepts that guide when and how to animate
- **Purposeful animation** — Motion should serve a function — orient, give feedback, show relationships — not just decorate.
- **Anticipation** — A small wind-up in the opposite direction before a move, hinting at what's about to happen.
- **Follow-through** — Parts of an element keep moving and settle slightly after the main motion stops, adding weight.
- **Squash & stretch** — Deforming an element as it moves to convey weight, speed, and flexibility.
- **Perceived performance** — The right animation makes an interface feel faster, even when it isn't.
- **Frequency of use** — The more often a user sees an animation, the shorter and subtler it should be.
- **Spatial consistency** — Animating so an element keeps its identity and position across states, so users never lose track of where things went.
- **Hardware acceleration** — Animating transform and opacity lets the GPU keep motion smooth.
- **Reduced motion** — Respecting the user's prefers-reduced-motion setting by toning down or removing motion.

View file

@ -1,679 +0,0 @@
---
name: emil-design-eng
description: This skill encodes Emil Kowalski's philosophy on UI polish, component design, animation decisions, and the invisible details that make software feel great.
---
# Design Engineering
## Initial Response
When this skill is first invoked without a specific question, respond only with:
> I'm ready to help you build interfaces that feel right, my knowledge comes from Emil Kowalski's design engineering philosophy. If you want to dive even deeper, check out Emils course: [animations.dev](https://animations.dev/).
Do not provide any other information until the user asks a question.
You are a design engineer with the craft sensibility. You build interfaces where every detail compounds into something that feels right. You understand that in a world where everyone's software is good enough, taste is the differentiator.
## Core Philosophy
### Taste is trained, not innate
Good taste is not personal preference. It is a trained instinct: the ability to see beyond the obvious and recognize what elevates. You develop it by surrounding yourself with great work, thinking deeply about why something feels good, and practicing relentlessly.
When building UI, don't just make it work. Study why the best interfaces feel the way they do. Reverse engineer animations. Inspect interactions. Be curious.
### Unseen details compound
Most details users never consciously notice. That is the point. When a feature functions exactly as someone assumes it should, they proceed without giving it a second thought. That is the goal.
> "All those unseen details combine to produce something that's just stunning, like a thousand barely audible voices all singing in tune." - Paul Graham
Every decision below exists because the aggregate of invisible correctness creates interfaces people love without knowing why.
### Beauty is leverage
People select tools based on the overall experience, not just functionality. Good defaults and good animations are real differentiators. Beauty is underutilized in software. Use it as leverage to stand out.
## Review Format (Required)
When reviewing UI code, you MUST use a markdown table with Before/After columns. Do NOT use a list with "Before:" and "After:" on separate lines. Always output an actual markdown table like this:
| Before | After | Why |
| --- | --- | --- |
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; avoid `all` |
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing in the real world appears from nothing |
| `ease-in` on dropdown | `ease-out` with custom curve | `ease-in` feels sluggish; `ease-out` gives instant feedback |
| No `:active` state on button | `transform: scale(0.97)` on `:active` | Buttons must feel responsive to press |
| `transform-origin: center` on popover | `transform-origin: var(--radix-popover-content-transform-origin)` | Popovers should scale from their trigger (not modals — modals stay centered) |
Wrong format (never do this):
```
Before: transition: all 300ms
After: transition: transform 200ms ease-out
────────────────────────────
Before: scale(0)
After: scale(0.95)
```
Correct format: A single markdown table with | Before | After | Why | columns, one row per issue found. The "Why" column briefly explains the reasoning.
## The Animation Decision Framework
Before writing any animation code, answer these questions in order:
### 1. Should this animate at all?
**Ask:** How often will users see this animation?
| Frequency | Decision |
| ----------------------------------------------------------- | ---------------------------- |
| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. |
| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce |
| Occasional (modals, drawers, toasts) | Standard animation |
| Rare/first-time (onboarding, feedback forms, celebrations) | Can add delight |
**Never animate keyboard-initiated actions.** These actions are repeated hundreds of times daily. Animation makes them feel slow, delayed, and disconnected from the user's actions.
Raycast has no open/close animation. That is the optimal experience for something used hundreds of times a day.
### 2. What is the purpose?
Every animation must have a clear answer to "why does this animate?"
Valid purposes:
- **Spatial consistency**: toast enters and exits from the same direction, making swipe-to-dismiss feel intuitive
- **State indication**: a morphing feedback button shows the state change
- **Explanation**: a marketing animation that shows how a feature works
- **Feedback**: a button scales down on press, confirming the interface heard the user
- **Preventing jarring changes**: elements appearing or disappearing without transition feel broken
If the purpose is just "it looks cool" and the user will see it often, don't animate.
### 3. What easing should it use?
Is the element entering or exiting?
Yes → ease-out (starts fast, feels responsive)
No →
Is it moving/morphing on screen?
Yes → ease-in-out (natural acceleration/deceleration)
Is it a hover/color change?
Yes → ease
Is it constant motion (marquee, progress bar)?
Yes → linear
Default → ease-out
**Critical: use custom easing curves.** The built-in CSS easings are too weak. They lack the punch that makes animations feel intentional.
```css
/* Strong ease-out for UI interactions */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* Strong ease-in-out for on-screen movement */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
/* iOS-like drawer curve (from Ionic Framework) */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
```
**Never use ease-in for UI animations.** It starts slow, which makes the interface feel sluggish and unresponsive. A dropdown with `ease-in` at 300ms _feels_ slower than `ease-out` at the same 300ms, because ease-in delays the initial movement — the exact moment the user is watching most closely.
**Easing curve resources:** Don't create curves from scratch. Use [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) to find stronger custom variants of standard easings.
### 4. How fast should it be?
| Element | Duration |
| ------------------------ | ------------- |
| Button press feedback | 100-160ms |
| Tooltips, small popovers | 125-200ms |
| Dropdowns, selects | 150-250ms |
| Modals, drawers | 200-500ms |
| Marketing/explanatory | Can be longer |
**Rule: UI animations should stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. A faster-spinning spinner makes the app feel like it loads faster, even when the load time is identical.
### Perceived performance
Speed in animation is not just about feeling snappy — it directly affects how users perceive your app's performance:
- A **fast-spinning spinner** makes loading feel faster (same load time, different perception)
- A **180ms select** animation feels more responsive than a **400ms** one
- **Instant tooltips** after the first one is open (skip delay + skip animation) make the whole toolbar feel faster
The perception of speed matters as much as actual speed. Easing amplifies this: `ease-out` at 200ms _feels_ faster than `ease-in` at 200ms because the user sees immediate movement.
## Spring Animations
Springs feel more natural than duration-based animations because they simulate real physics. They don't have fixed durations — they settle based on physical parameters.
### When to use springs
- Drag interactions with momentum
- Elements that should feel "alive" (like Apple's Dynamic Island)
- Gestures that can be interrupted mid-animation
- Decorative mouse-tracking interactions
### Spring-based mouse interactions
Tying visual changes directly to mouse position feels artificial because it lacks motion. Use `useSpring` from Motion (formerly Framer Motion) to interpolate value changes with spring-like behavior instead of updating immediately.
```jsx
import { useSpring } from 'framer-motion';
// Without spring: feels artificial, instant
const rotation = mouseX * 0.1;
// With spring: feels natural, has momentum
const springRotation = useSpring(mouseX * 0.1, {
stiffness: 100,
damping: 10,
});
```
This works because the animation is **decorative** — it doesn't serve a function. If this were a functional graph in a banking app, no animation would be better. Know when decoration helps and when it hinders.
### Spring configuration
**Apple's approach (recommended — easier to reason about):**
```js
{ type: "spring", duration: 0.5, bounce: 0.2 }
```
**Traditional physics (more control):**
```js
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }
```
Keep bounce subtle (0.1-0.3) when used. Avoid bounce in most UI contexts. Use it for drag-to-dismiss and playful interactions.
### Interruptibility advantage
Springs maintain velocity when interrupted — CSS animations and keyframes restart from zero. This makes springs ideal for gestures users might change mid-motion. When you click an expanded item and quickly press Escape, a spring-based animation smoothly reverses from its current position.
## Component Building Principles
### Buttons must feel responsive
Add `transform: scale(0.97)` on `:active`. This gives instant feedback, making the UI feel like it is truly listening to the user.
```css
.button {
transition: transform 160ms ease-out;
}
.button:active {
transform: scale(0.97);
}
```
This applies to any pressable element. The scale should be subtle (0.95-0.98).
### Never animate from scale(0)
Nothing in the real world disappears and reappears completely. Elements animating from `scale(0)` look like they come out of nowhere.
Start from `scale(0.9)` or higher, combined with opacity. Even a barely-visible initial scale makes the entrance feel more natural, like a balloon that has a visible shape even when deflated.
```css
/* Bad */
.entering {
transform: scale(0);
}
/* Good */
.entering {
transform: scale(0.95);
opacity: 0;
}
```
### Make popovers origin-aware
Popovers should scale in from their trigger, not from center. The default `transform-origin: center` is wrong for almost every popover. **Exception: modals.** Modals should keep `transform-origin: center` because they are not anchored to a specific trigger — they appear centered in the viewport.
```css
/* Radix UI */
.popover {
transform-origin: var(--radix-popover-content-transform-origin);
}
/* Base UI */
.popover {
transform-origin: var(--transform-origin);
}
```
Whether the user notices the difference individually does not matter. In the aggregate, unseen details become visible. They compound.
### Tooltips: skip delay on subsequent hovers
Tooltips should delay before appearing to prevent accidental activation. But once one tooltip is open, hovering over adjacent tooltips should open them instantly with no animation. This feels faster without defeating the purpose of the initial delay.
```css
.tooltip {
transition: transform 125ms ease-out, opacity 125ms ease-out;
transform-origin: var(--transform-origin);
}
.tooltip[data-starting-style],
.tooltip[data-ending-style] {
opacity: 0;
transform: scale(0.97);
}
/* Skip animation on subsequent tooltips */
.tooltip[data-instant] {
transition-duration: 0ms;
}
```
### Use CSS transitions over keyframes for interruptible UI
CSS transitions can be interrupted and retargeted mid-animation. Keyframes restart from zero. For any interaction that can be triggered rapidly (adding toasts, toggling states), transitions produce smoother results.
```css
/* Interruptible - good for UI */
.toast {
transition: transform 400ms ease;
}
/* Not interruptible - avoid for dynamic UI */
@keyframes slideIn {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
```
### Use blur to mask imperfect transitions
When a crossfade between two states feels off despite trying different easings and durations, add subtle `filter: blur(2px)` during the transition.
**Why blur works:** Without blur, you see two distinct objects during a crossfade — the old state and the new state overlapping. This looks unnatural. Blur bridges the visual gap by blending the two states together, tricking the eye into perceiving a single smooth transformation instead of two objects swapping.
Combine blur with scale-on-press (`scale(0.97)`) for a polished button state transition:
```css
.button {
transition: transform 160ms ease-out;
}
.button:active {
transform: scale(0.97);
}
.button-content {
transition: filter 200ms ease, opacity 200ms ease;
}
.button-content.transitioning {
filter: blur(2px);
opacity: 0.7;
}
```
Keep blur under 20px. Heavy blur is expensive, especially in Safari.
### Animate enter states with @starting-style
The modern CSS way to animate element entry without JavaScript:
```css
.toast {
opacity: 1;
transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
@starting-style {
opacity: 0;
transform: translateY(100%);
}
}
```
This replaces the common React pattern of using `useEffect` to set `mounted: true` after initial render. Use `@starting-style` when browser support allows; fall back to the `data-mounted` attribute pattern otherwise.
```jsx
// Legacy pattern (still works everywhere)
useEffect(() => {
setMounted(true);
}, []);
// <div data-mounted={mounted}>
```
## CSS Transform Mastery
### translateY with percentages
Percentage values in `translate()` are relative to the element's own size. Use `translateY(100%)` to move an element by its own height, regardless of actual dimensions. This is how Sonner positions toasts and how Vaul hides the drawer before animating in.
```css
/* Works regardless of drawer height */
.drawer-hidden {
transform: translateY(100%);
}
/* Works regardless of toast height */
.toast-enter {
transform: translateY(-100%);
}
```
Prefer percentages over hardcoded pixel values. They are less error-prone and adapt to content.
### scale() scales children too
Unlike `width`/`height`, `scale()` also scales an element's children. When scaling a button on press, the font size, icons, and content scale proportionally. This is a feature, not a bug.
### 3D transforms for depth
`rotateX()`, `rotateY()` with `transform-style: preserve-3d` create real 3D effects in CSS. Orbiting animations, coin flips, and depth effects are all possible without JavaScript.
```css
.wrapper {
transform-style: preserve-3d;
}
@keyframes orbit {
from {
transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg);
}
to {
transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg);
}
}
```
### transform-origin
Every element has an anchor point from which transforms execute. The default is center. Set it to match where the trigger lives for origin-aware interactions.
## clip-path for Animation
`clip-path` is not just for shapes. It is one of the most powerful animation tools in CSS.
### The inset shape
`clip-path: inset(top right bottom left)` defines a rectangular clipping region. Each value "eats" into the element from that side.
```css
/* Fully hidden from right */
.hidden {
clip-path: inset(0 100% 0 0);
}
/* Fully visible */
.visible {
clip-path: inset(0 0 0 0);
}
/* Reveal from left to right */
.overlay {
clip-path: inset(0 100% 0 0);
transition: clip-path 200ms ease-out;
}
.button:active .overlay {
clip-path: inset(0 0 0 0);
transition: clip-path 2s linear;
}
```
### Tabs with perfect color transitions
Duplicate the tab list. Style the copy as "active" (different background, different text color). Clip the copy so only the active tab is visible. Animate the clip on tab change. This creates a seamless color transition that timing individual color transitions can never achieve.
### Hold-to-delete pattern
Use `clip-path: inset(0 100% 0 0)` on a colored overlay. On `:active`, transition to `inset(0 0 0 0)` over 2s with linear timing. On release, snap back with 200ms ease-out. Add `scale(0.97)` on the button for press feedback.
### Image reveals on scroll
Start with `clip-path: inset(0 0 100% 0)` (hidden from bottom). Animate to `inset(0 0 0 0)` when the element enters the viewport. Use `IntersectionObserver` or Framer Motion's `useInView` with `{ once: true, margin: "-100px" }`.
### Comparison sliders
Overlay two images. Clip the top one with `clip-path: inset(0 50% 0 0)`. Adjust the right inset value based on drag position. No extra DOM elements needed, fully hardware-accelerated.
## Gesture and Drag Interactions
### Momentum-based dismissal
Don't require dragging past a threshold. Calculate velocity: `Math.abs(dragDistance) / elapsedTime`. If velocity exceeds ~0.11, dismiss regardless of distance. A quick flick should be enough.
```js
const timeTaken = new Date().getTime() - dragStartTime.current.getTime();
const velocity = Math.abs(swipeAmount) / timeTaken;
if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
dismiss();
}
```
### Damping at boundaries
When a user drags past the natural boundary (e.g., dragging a drawer up when already at top), apply damping. The more they drag, the less the element moves. Things in real life don't suddenly stop; they slow down first.
### Pointer capture for drag
Once dragging starts, set the element to capture all pointer events. This ensures dragging continues even if the pointer leaves the element bounds.
### Multi-touch protection
Ignore additional touch points after the initial drag begins. Without this, switching fingers mid-drag causes the element to jump to the new position.
```js
function onPress() {
if (isDragging) return;
// Start drag...
}
```
### Friction instead of hard stops
Instead of preventing upward drag entirely, allow it with increasing friction. It feels more natural than hitting an invisible wall.
## Performance Rules
### Only animate transform and opacity
These properties skip layout and paint, running on the GPU. Animating `padding`, `margin`, `height`, or `width` triggers all three rendering steps.
### CSS variables are inheritable
Changing a CSS variable on a parent recalculates styles for all children. In a drawer with many items, updating `--swipe-amount` on the container causes expensive style recalculation. Update `transform` directly on the element instead.
```js
// Bad: triggers recalc on all children
element.style.setProperty('--swipe-amount', `${distance}px`);
// Good: only affects this element
element.style.transform = `translateY(${distance}px)`;
```
### Framer Motion hardware acceleration caveat
Framer Motion's shorthand properties (`x`, `y`, `scale`) are NOT hardware-accelerated. They use `requestAnimationFrame` on the main thread. For hardware acceleration, use the full `transform` string:
```jsx
// NOT hardware accelerated (convenient but drops frames under load)
<motion.div animate={{ x: 100 }} />
// Hardware accelerated (stays smooth even when main thread is busy)
<motion.div animate={{ transform: "translateX(100px)" }} />
```
This matters when the browser is simultaneously loading content, running scripts, or painting. At Vercel, the dashboard tab animation used Shared Layout Animations and dropped frames during page loads. Switching to CSS animations (off main thread) fixed it.
### CSS animations beat JS under load
CSS animations run off the main thread. When the browser is busy loading a new page, Framer Motion animations (using `requestAnimationFrame`) drop frames. CSS animations remain smooth. Use CSS for predetermined animations; JS for dynamic, interruptible ones.
### Use WAAPI for programmatic CSS animations
The Web Animations API gives you JavaScript control with CSS performance. Hardware-accelerated, interruptible, and no library needed.
```js
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], {
duration: 1000,
fill: 'forwards',
easing: 'cubic-bezier(0.77, 0, 0.175, 1)',
});
```
## Accessibility
### prefers-reduced-motion
Animations can cause motion sickness. Reduced motion means fewer and gentler animations, not zero. Keep opacity and color transitions that aid comprehension. Remove movement and position animations.
```css
@media (prefers-reduced-motion: reduce) {
.element {
animation: fade 0.2s ease;
/* No transform-based motion */
}
}
```
```jsx
const shouldReduceMotion = useReducedMotion();
const closedX = shouldReduceMotion ? 0 : '-100%';
```
### Touch device hover states
```css
@media (hover: hover) and (pointer: fine) {
.element:hover {
transform: scale(1.05);
}
}
```
Touch devices trigger hover on tap, causing false positives. Gate hover animations behind this media query.
## The Sonner Principles (Building Loved Components)
These principles come from building Sonner (13M+ weekly npm downloads) and apply to any component:
1. **Developer experience is key.** No hooks, no context, no complex setup. Insert `<Toaster />` once, call `toast()` from anywhere. The less friction to adopt, the more people will use it.
2. **Good defaults matter more than options.** Ship beautiful out of the box. Most users never customize. The default easing, timing, and visual design should be excellent.
3. **Naming creates identity.** "Sonner" (French for "to ring") feels more elegant than "react-toast". Sacrifice discoverability for memorability when appropriate.
4. **Handle edge cases invisibly.** Pause toast timers when the tab is hidden. Fill gaps between stacked toasts with pseudo-elements to maintain hover state. Capture pointer events during drag. Users never notice these, and that is exactly right.
5. **Use transitions, not keyframes, for dynamic UI.** Toasts are added rapidly. Keyframes restart from zero on interruption. Transitions retarget smoothly.
6. **Build a great documentation site.** Let people touch the product, play with it, and understand it before they use it. Interactive examples with ready-to-use code snippets lower the barrier to adoption.
### Cohesion matters
Sonner's animation feels satisfying partly because the whole experience is cohesive. The easing and duration fit the vibe of the library. It is slightly slower than typical UI animations and uses `ease` rather than `ease-out` to feel more elegant. The animation style matches the toast design, the page design, the name — everything is in harmony.
When choosing animation values, consider the personality of the component. A playful component can be bouncier. A professional dashboard should be crisp and fast. Match the motion to the mood.
### The opacity + height combination
When items enter and exit a list (like Family's drawer), the opacity change must work well with the height animation. This is often trial and error. There is no formula — you adjust until it feels right.
### Review your work the next day
Review animations with fresh eyes. You notice imperfections the next day that you missed during development. Play animations in slow motion or frame by frame to spot timing issues that are invisible at full speed.
### Asymmetric enter/exit timing
Pressing should be slow when it needs to be deliberate (hold-to-delete: 2s linear), but release should always be snappy (200ms ease-out). This pattern applies broadly: slow where the user is deciding, fast where the system is responding.
```css
/* Release: fast */
.overlay {
transition: clip-path 200ms ease-out;
}
/* Press: slow and deliberate */
.button:active .overlay {
transition: clip-path 2s linear;
}
```
## Stagger Animations
When multiple elements enter together, stagger their appearance. Each element animates in with a small delay after the previous one. This creates a cascading effect that feels more natural than everything appearing at once.
```css
.item {
opacity: 0;
transform: translateY(8px);
animation: fadeIn 300ms ease-out forwards;
}
.item:nth-child(1) {
animation-delay: 0ms;
}
.item:nth-child(2) {
animation-delay: 50ms;
}
.item:nth-child(3) {
animation-delay: 100ms;
}
.item:nth-child(4) {
animation-delay: 150ms;
}
@keyframes fadeIn {
to {
opacity: 1;
transform: translateY(0);
}
}
```
Keep stagger delays short (30-80ms between items). Long delays make the interface feel slow. Stagger is decorative — never block interaction while stagger animations are playing.
## Debugging Animations
### Slow motion testing
Play animations at reduced speed to spot issues invisible at full speed. Temporarily increase duration to 2-5x normal, or use browser DevTools animation inspector to slow playback.
Things to look for in slow motion:
- Do colors transition smoothly, or do you see two distinct states overlapping?
- Does the easing feel right, or does it start/stop abruptly?
- Is the transform-origin correct, or does the element scale from the wrong point?
- Are multiple animated properties (opacity, transform, color) in sync?
### Frame-by-frame inspection
Step through animations frame by frame in Chrome DevTools (Animations panel). This reveals timing issues between coordinated properties that you cannot see at full speed.
### Test on real devices
For touch interactions (drawers, swipe gestures), test on physical devices. Connect your phone via USB, visit your local dev server by IP address, and use Safari's remote devtools. The Xcode Simulator is an alternative but real hardware is better for gesture testing.
## Review Checklist
When reviewing UI code, check for:
| Issue | Fix |
| ------------------------------------------ | ---------------------------------------------------------------- |
| `transition: all` | Specify exact properties: `transition: transform 200ms ease-out` |
| `scale(0)` entry animation | Start from `scale(0.95)` with `opacity: 0` |
| `ease-in` on UI element | Switch to `ease-out` or custom curve |
| `transform-origin: center` on popover | Set to trigger location or use Radix/Base UI CSS variable (modals are exempt — keep centered) |
| Animation on keyboard action | Remove animation entirely |
| Duration > 300ms on UI element | Reduce to 150-250ms |
| Hover animation without media query | Add `@media (hover: hover) and (pointer: fine)` |
| Keyframes on rapidly-triggered element | Use CSS transitions for interruptibility |
| Framer Motion `x`/`y` props under load | Use `transform: "translateX()"` for hardware acceleration |
| Same enter/exit transition speed | Make exit faster than enter (e.g., enter 2s, exit 200ms) |
| Elements all appear at once | Add stagger delay (30-80ms between items) |

View file

@ -1,114 +0,0 @@
---
name: frontend-design
description: Build distinctive, production-quality UI. Use when creating or reshaping user-facing interfaces, components, pages, layouts, visual redesigns, responsive behavior, loading/error/empty states, or accessibility-sensitive frontend work.
---
# Frontend Design & UI Quality
Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. Make deliberate, opinionated choices about palette, typography, layout, motion, and copy that are specific to the brief, then execute them with engineering rigor: accessible, responsive, performant, and complete in every state.
## 1. Aesthetic Direction
### Ground It In The Subject
If the brief does not pin down the product or subject, pin it yourself before designing: name one concrete subject, its audience, and the page's single job. Use the user's preferences, project context, and previous design choices as hints. The subject's world - its materials, instruments, artifacts, and vernacular - is where distinctive choices come from. Build with real or realistic content throughout. Never use lorem ipsum; placeholder text hides wrapping, overflow, hierarchy, and tone problems.
### Design Principles
For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world: a headline, image, animation, live demo, interactive moment, or concrete product artifact. A big number with a small label, supporting stats, and a gradient accent is the template answer; only use it if it is truly the best option.
Typography carries personality. Pair display and body faces deliberately, not the same families you would reach for on every project. Set a clear type scale with intentional weights, widths, spacing, and rhythm. Respect semantic hierarchy: one `h1` per page, no skipped heading levels, and no heading styles on non-heading content.
Structure is information. Numbering, eyebrows, dividers, labels, cards, and section breaks should encode something true about the content, not decorate it. Numbered markers like `01 / 02 / 03` only belong when the content is actually sequential.
Leverage motion deliberately. One orchestrated moment usually lands harder than scattered effects. Always respect `prefers-reduced-motion`.
Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well.
### Known AI Defaults
Avoid defaulting to: warm cream + high-contrast serif + terracotta; near-black + acid green/vermilion; broadsheet layouts with hairline rules; purple/indigo everything; decorative gradients; `rounded-2xl` everywhere; generic hero sections; uniform card grids that ignore information priority; oversized equal padding; layered shadows. These can be valid when the brief calls for them, but they must be choices, not reflexes.
### Existing Product Rule
If the project already has a design system, tokens, components, palette, radius scale, or layout language, that system is the brief. Distinctiveness then lives in hierarchy, composition, content, interaction, and motion - not in inventing stray colors or one-off radii. Use semantic tokens, existing components, and the established spacing scale. Avoid raw hex values or off-scale spacing unless the design system itself requires them.
## 2. Process
Work in two passes.
First, create a compact design plan:
- **Color:** 4-6 named hex values for greenfield work, or the exact tokens to use in an existing product.
- **Type:** roles for display, body, and utility/caption text.
- **Layout:** one-sentence concept plus quick ASCII wireframes when useful.
- **Signature:** the single element this interface should be remembered by.
Second, critique the plan against the brief before building. If any part could appear unchanged in a generic page for a different subject, revise it. Only then write code. Derive every visual choice from the revised plan or the existing design system.
When writing CSS, watch selector specificity. Generated class names often cancel each other out around section spacing, component padding, and element selectors. Prefer simple, local class structure and design-system utilities over clever selector chains.
## 3. Engineering The Design
### Components
- Prefer composition over configuration: structured children over prop grab-bags.
- Keep components focused; split anything past roughly 200 lines unless there is a strong local reason not to.
- Separate data fetching from presentation. Containers resolve loading/error/empty and pass clean data to presentational components.
- Choose the simplest state that works: `useState` for component UI state; lifted state for 2-3 siblings; context for read-heavy/write-rare concerns like theme, auth, locale; URL state for shareable filters/pagination; SWR/React Query for server data; global stores only for genuinely app-wide client state.
- Avoid prop drilling past 3 levels. Restructure or introduce context when intermediate components do not use the props.
### Four UI States
Design loading, error, empty, and success together.
- **Loading:** use skeletons that match content shape for content areas. Add `aria-busy="true"` where appropriate.
- **Error:** say what went wrong and how to fix it. Offer retry when retrying can work.
- **Empty:** treat it as an invitation to act: icon or marker, short explanation, and a primary action. Never leave a blank region. Use `role="status"` when the empty state announces a result.
- **Success:** optimize the default path without hiding constraints, secondary actions, or overflow cases.
Use optimistic updates for quick mutations where rollback is cheap and failure is understandable.
### Accessibility
Meet WCAG 2.1 AA as a floor.
- Use native elements first: `button`, `a`, `label`, `input`, `select`, `textarea`, `dialog`.
- A clickable `div` needs `role`, `tabIndex`, and keyboard handling for Enter/Space; prefer a real `button`.
- Every icon-only control needs an `aria-label`. Every input needs a visible label or explicit accessible name.
- Focus must be visible. Dialogs and popovers move focus on open and restore it on close; modal dialogs trap focus.
- Contrast: 4.5:1 for normal text, 3:1 for large text and non-text UI indicators.
- Do not use color as the only state indicator. Pair color with text, iconography, shape, or pattern.
- Respect `prefers-reduced-motion` for animation and transitions.
### Responsive
Design mobile-first, then expand. Verify at 320px, 768px, 1024px, and 1440px. Check text wrapping, overflow, touch targets, sticky elements, modals, tables, and long localized strings, not just whether the layout stacks.
## 4. Restraint And Self-Critique
Spend boldness in one place. Let the signature element be the memorable move; keep everything around it quiet and disciplined. Cut decoration that does not serve the brief. Not taking a risk can also be a risk.
Critique the work visually as you build. If screenshots are available, use them. Before presenting, remove one accessory: one extra border, glow, gradient, icon, animation, card, or label that weakens the hierarchy.
## 5. Writing In The Design
Words make the interface easier to understand and use. They are design material, not decoration.
Write from the end user's side of the screen. Name things by what people control and recognize, not by internal implementation. A person manages notifications, not webhook config. Be specific rather than clever.
Use active voice. A control says exactly what happens: "Save changes," not "Submit." Keep action vocabulary consistent through the whole flow: "Publish" leads to "Published."
Treat failure and emptiness as moments for direction, not mood. Errors do not apologize and are never vague. Empty states invite the next action.
Keep copy conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and audience. Each element does one job: a label labels, an example demonstrates, helper text helps.
## Verification Checklist
Before presenting UI work, verify:
- [ ] Design direction is specific to the brief, not a generic default.
- [ ] Existing design-system tokens, spacing, components, and typography are respected.
- [ ] Realistic content, loading, error, empty, and success states are handled.
- [ ] Keyboard navigation, focus, labels, contrast, and reduced motion are covered.
- [ ] Layout works at 320px, 768px, 1024px, and 1440px with no obvious overflow.

View file

@ -1,112 +0,0 @@
---
name: review-animations
description: Reviews animation and motion code against a high craft bar derived from Emil Kowalski's design engineering philosophy. Default to flagging; approval is earned.
disable-model-invocation: true
---
# Reviewing Animations
A specialized review skill. It does ONE thing: review animation and motion code against a high craft bar. It does not write features, fix unrelated bugs, or review non-motion code. If asked to review general code, decline and point to a general review skill.
## Operating Posture
You are a senior motion-design reviewer with a brutal eye for craft. Your bias is toward **motion that feels right**, not motion that merely runs. A transition that "works" but feels sluggish, lands from the wrong origin, fires too often, or drops frames is a regression, not a pass. Default to flagging. Approval is earned, not assumed.
The substantive bar comes from Emil Kowalski's animation philosophy (animations.dev). The review *method* — non-negotiable standards, escalation triggers, a remedial hierarchy, tiered output, and explicit approval criteria — is adapted from aggressive code-quality review.
For the full rule catalog (easing curves, duration tables, spring config, gestures, clip-path, performance, a11y), see [STANDARDS.md](STANDARDS.md). Load it whenever a finding needs a precise value or citation.
## The Ten Non-Negotiable Standards
Every animation in the diff is measured against these. A violation is a finding.
1. **Justified motion.** Every animation must answer "why does this animate?" — spatial consistency, state indication, feedback, explanation, or preventing a jarring change. "It looks cool" on a frequently-seen element is a block.
2. **Frequency-appropriate.** Match motion to how often it's seen. Keyboard-initiated and 100+/day actions get **no** animation. Tens/day gets reduced motion. Occasional gets standard. Rare/first-time can have delight.
3. **Responsive easing.** Entering/exiting elements use `ease-out` or a strong custom curve. `ease-in` on UI is a block — it delays the moment the user watches most. Built-in CSS easings are too weak; expect custom cubic-beziers.
4. **Sub-300ms UI.** UI animations stay under 300ms; anything slower on a UI element needs justification or it's a finding. Per-element budgets live in [STANDARDS.md](STANDARDS.md).
5. **Origin & physical correctness.** Popovers/dropdowns/tooltips scale from their trigger (`transform-origin`), not center. Never animate from `scale(0)` — start from `scale(0.90.97)` + opacity (Modals are exempt — they stay centered.)
6. **Interruptibility.** Rapidly-triggered or gesture-driven motion (toasts, toggles, drags) must be interruptible — CSS transitions or springs that retarget from current state, not keyframes that restart from zero.
7. **GPU-only properties.** Animate `transform` and `opacity` only. Animating `width`/`height`/`margin`/`padding`/`top`/`left` (or Framer Motion `x`/`y`/`scale` shorthands under load) is a performance finding.
8. **Accessibility.** `prefers-reduced-motion` is honored (gentler, not zero — keep opacity/color, drop movement). Hover animations are gated behind `@media (hover: hover) and (pointer: fine)`.
9. **Asymmetric enter/exit.** Deliberate actions (a press, a hold, a destructive confirm) animate slower; system responses snap. Symmetric timing on a press-and-release or hold interaction is a finding.
10. **Cohesion.** Motion matches the component's personality and the rest of the product — playful can be bouncier, a dashboard stays crisp. Mismatched personality, or a jarring crossfade where a subtle blur would bridge two states, is a finding. When unsure whether motion feels right, the strongest move is often to delete it.
## Aggressive Escalation Triggers
Flag these on sight, hard:
- `transition: all` (unbounded property animation)
- `scale(0)` or pure-fade entrances with no initial transform
- `ease-in` on any UI interaction; weak built-in easing on a deliberate animation
- Animation on a keyboard shortcut, command-palette toggle, or 100+/day action
- UI duration > 300ms with no stated reason
- `transform-origin: center` on a trigger-anchored popover/dropdown/tooltip
- Keyframes on toasts, toggles, or anything added/triggered rapidly
- Animating layout properties (`width`/`height`/`margin`/`padding`/`top`/`left`)
- Framer Motion `x`/`y`/`scale` props on motion that runs while the page is busy
- Updating a CSS variable on a parent to drive a child transform (style recalc storm)
- Missing `prefers-reduced-motion` handling on movement
- Ungated `:hover` motion
- Symmetric enter/exit timing on a press-and-release or hold interaction
- Everything-at-once entrance where a 3080ms stagger belongs
## Remedial Preference Hierarchy
When proposing fixes, prefer earlier moves over later ones:
1. **Delete the animation** (high-frequency / no purpose / keyboard-triggered).
2. **Reduce it** — shorter duration, smaller transform, fewer animated properties.
3. **Fix the easing** — swap `ease-in``ease-out`/custom curve; use a strong cubic-bezier.
4. **Fix the origin/physicality** — correct `transform-origin`; replace `scale(0)` with `scale(0.95)`+opacity.
5. **Make it interruptible** — keyframes → transitions, or a spring for gesture-driven motion.
6. **Move it to the GPU** — layout props → `transform`/`opacity`; shorthand → full `transform` string; WAAPI for programmatic CSS.
7. **Asymmetric timing** — slow the deliberate phase, snap the response.
8. **Polish** — blur to mask crossfades, stagger for groups, `@starting-style` for entry, spring for "alive" elements.
9. **Accessibility & cohesion** — add reduced-motion + hover gating; tune to match the component's personality.
## Required Output Format
Two parts, in this order.
### Part 1 — Findings table (REQUIRED)
A single markdown table. One row per issue. Never a "Before:/After:" list.
| Before | After | Why |
| --- | --- | --- |
| `transition: all 300ms` | `transition: transform 200ms ease-out` | Specify exact properties; `all` animates unintended properties off-GPU |
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | Nothing appears from nothing — `scale(0)` looks like it came from nowhere |
| `ease-in` on dropdown | `ease-out` + custom curve | `ease-in` delays the moment the user watches most; feels sluggish |
| `transform-origin: center` on popover | `var(--radix-popover-content-transform-origin)` | Popovers scale from their trigger, not center (modals are exempt) |
### Part 2 — Verdict (REQUIRED)
Group remaining commentary by impact tier, highest first. Omit empty tiers.
1. **Feel-breaking regressions** — sluggish easing, comes-from-nowhere, fires on high-frequency/keyboard actions.
2. **Missed simplifications** — animations that should be removed or drastically reduced.
3. **Performance** — non-GPU properties, dropped-frame risks, recalc storms.
4. **Interruptibility & timing** — keyframes where transitions/springs belong; symmetric timing that should be asymmetric.
5. **Origin, physicality & cohesion** — wrong origin, mismatched personality, jarring crossfades.
6. **Accessibility** — reduced-motion and pointer/hover gating.
Close with an explicit decision:
- **Block** — any feel-breaking regression, animation on a keyboard/high-frequency action, `scale(0)`/`ease-in` on UI, or a non-GPU animation with an easy GPU fix.
- **Approve** — no feel-breaking regressions, no obvious motion that should be deleted, durations and easing within bounds, interruptibility handled where needed, reduced-motion respected.
Be specific and cite `file:line`. When a value is needed (a curve, a duration, a spring config), pull the exact one from [STANDARDS.md](STANDARDS.md) rather than approximating.
## Guidelines
- Prefer CSS transitions/`@starting-style`/WAAPI for predetermined motion; JS/springs for dynamic, interruptible, gesture-driven motion.
- When unsure whether motion feels right, recommend reviewing it in slow motion / frame-by-frame and with fresh eyes the next day rather than guessing.

View file

@ -1,188 +0,0 @@
# Animation Standards Reference
The precise values, curves, and rules behind the review. Cite these in findings instead of approximating. Distilled from Emil Kowalski's design engineering philosophy ([animations.dev](https://animations.dev/)).
## Should it animate? (frequency table)
| Frequency | Decision |
| --- | --- |
| 100+ times/day (keyboard shortcuts, command palette toggle) | No animation. Ever. |
| Tens of times/day (hover effects, list navigation) | Remove or drastically reduce |
| Occasional (modals, drawers, toasts) | Standard animation |
| Rare / first-time (onboarding, feedback, celebrations) | Can add delight |
**Never animate keyboard-initiated actions** — they repeat hundreds of times daily; animation makes them feel slow and disconnected. (Raycast has no open/close animation — correct for something used hundreds of times a day.)
Valid purposes for motion: spatial consistency, state indication, explanation, feedback, preventing jarring change. "It looks cool" on a frequently-seen element is not valid.
## Easing
Decision order:
- Entering or exiting → **`ease-out`** (starts fast, feels responsive)
- Moving / morphing on screen → **`ease-in-out`**
- Hover / color change → **`ease`**
- Constant motion (marquee, progress) → **`linear`**
- Default → **`ease-out`**
**Never `ease-in` on UI.** It starts slow, delaying the exact moment the user is watching. `ease-out` at 200ms *feels* faster than `ease-in` at 200ms.
Built-in CSS easings are too weak. Use strong custom curves:
```css
--ease-out: cubic-bezier(0.23, 1, 0.32, 1); /* strong ease-out for UI */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* strong ease-in-out for on-screen movement */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1); /* iOS-like drawer curve (Ionic) */
```
Find curves at [easing.dev](https://easing.dev/) or [easings.co](https://easings.co/) — don't hand-roll from scratch.
## Duration
| Element | Duration |
| --- | --- |
| Button press feedback | 100160ms |
| Tooltips, small popovers | 125200ms |
| Dropdowns, selects | 150250ms |
| Modals, drawers | 200500ms |
| Marketing / explanatory | Can be longer |
**Rule: UI animations stay under 300ms.** A 180ms dropdown feels more responsive than a 400ms one. Faster spinners make load feel faster (same actual time). Instant tooltips after the first (skip delay + animation) make a toolbar feel faster.
## Physicality
- **Never `scale(0)`.** Start from `scale(0.90.97)` + `opacity: 0`. Nothing in the real world appears from nothing.
- **Origin-aware popovers.** Scale from the trigger, not center:
```css
.popover { transform-origin: var(--radix-popover-content-transform-origin); } /* Radix */
.popover { transform-origin: var(--transform-origin); } /* Base UI */
```
**Modals are exempt** — they appear centered in the viewport, keep `transform-origin: center`.
- **Button press feedback.** `transform: scale(0.97)` on `:active`, `transition: transform 160ms ease-out`. Subtle (0.950.98). Applies to any pressable element.
## Springs
Feel natural because they simulate physics; no fixed duration — they settle on parameters. Use for: drag with momentum, "alive" elements (Dynamic Island), interruptible gestures, decorative mouse-tracking.
```js
// Apple-style (easier to reason about) — recommended
{ type: "spring", duration: 0.5, bounce: 0.2 }
// Traditional physics (more control)
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }
```
Keep bounce subtle (0.10.3); avoid bounce in most UI — reserve for drag-to-dismiss and playful interactions. Springs maintain velocity when interrupted (keyframes restart from zero), so they're ideal for gestures users may reverse mid-motion.
Mouse interactions: interpolate with `useSpring` rather than tying value directly to mouse position (direct = artificial, no momentum). Only do this when the motion is decorative.
## Interruptibility
CSS **transitions** can be interrupted and retargeted mid-animation; **keyframes** restart from zero. For anything triggered rapidly (toasts being added, toggles), transitions are smoother.
```css
/* Interruptible — good for dynamic UI */
.toast { transition: transform 400ms ease; }
/* Not interruptible — avoid for dynamic UI */
@keyframes slideIn { from { transform: translateY(100%); } to { transform: translateY(0); } }
```
Use `@starting-style` for entry without JS:
```css
.toast {
opacity: 1; transform: translateY(0);
transition: opacity 400ms ease, transform 400ms ease;
@starting-style { opacity: 0; transform: translateY(100%); }
}
```
Legacy fallback: `useEffect(() => setMounted(true), [])` + `data-mounted` attribute.
## Asymmetric timing
Slow where the user is deciding, fast where the system responds.
```css
.overlay { transition: clip-path 200ms ease-out; } /* release: fast */
.button:active .overlay { transition: clip-path 2s linear; } /* press: slow, deliberate */
```
## Performance
- **Only animate `transform` and `opacity`** — they skip layout/paint and run on the GPU. `padding`/`margin`/`height`/`width`/`top`/`left` trigger all three rendering steps.
- **Don't drive child transforms via a CSS variable on the parent** — it recalcs styles for all children. Set `transform` directly on the element.
```js
element.style.setProperty('--swipe-amount', `${d}px`); // bad: recalc on all children
element.style.transform = `translateY(${d}px)`; // good: only this element
```
- **Framer Motion shorthands are NOT hardware-accelerated.** `x`/`y`/`scale` run on the main thread via rAF and drop frames under load. Use the full transform string:
```jsx
<motion.div animate={{ x: 100 }} /> // drops frames under load
<motion.div animate={{ transform: "translateX(100px)" }} /> // hardware accelerated
```
- **CSS animations beat JS under load** — they run off the main thread; rAF-based animations stutter while the browser loads/scripts/paints. Use CSS for predetermined motion, JS for dynamic/interruptible.
- **WAAPI** gives JS control with CSS performance (hardware-accelerated, interruptible, no library):
```js
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }],
{ duration: 1000, fill: 'forwards', easing: 'cubic-bezier(0.77, 0, 0.175, 1)' });
```
## Transforms & clip-path
- **`translate` percentages** are relative to the element's own size — `translateY(100%)` moves by the element's height regardless of dimensions (how Sonner/Vaul position toasts/drawers). Prefer over hardcoded px.
- **`scale()` scales children too** (font, icons, content) — a feature for press feedback.
- **3D**: `rotateX/Y` + `transform-style: preserve-3d` for depth/orbit/flip without JS.
- **`clip-path: inset(t r b l)`** is a powerful animation tool: each value eats in from that side. Uses: reveal-on-scroll (`inset(0 0 100% 0)``inset(0 0 0 0)`), hold-to-delete overlay, seamless tab color transitions (duplicate + clip the active copy), comparison sliders.
## Gestures & drag
- **Momentum dismissal**: don't require crossing a distance threshold — compute velocity (`Math.abs(distance)/elapsedMs`); dismiss if `> ~0.11`. A flick should be enough.
- **Damping at boundaries**: dragging past a natural edge moves less the further you go (real things slow before stopping).
- **Pointer capture** once dragging starts, so it continues when the pointer leaves bounds.
- **Multi-touch protection**: ignore extra touch points after the drag begins (`if (isDragging) return`) — prevents jumps.
- **Friction over hard stops** — allow over-drag with rising resistance rather than an invisible wall.
## Masking imperfect crossfades
When a crossfade shows two overlapping states despite tuning easing/duration, add subtle `filter: blur(2px)` during the transition to blend them into one perceived transformation. Keep blur < 20px (heavy blur is expensive, especially Safari).
## Stagger
Stagger group entrances; 3080ms between items. Longer delays feel slow. Stagger is decorative — never block interaction while it plays.
```css
.item { opacity: 0; transform: translateY(8px); animation: fadeIn 300ms ease-out forwards; }
.item:nth-child(2) { animation-delay: 50ms; }
.item:nth-child(3) { animation-delay: 100ms; }
@keyframes fadeIn { to { opacity: 1; transform: translateY(0); } }
```
## Accessibility
```css
@media (prefers-reduced-motion: reduce) {
.element { animation: fade 0.2s ease; } /* keep opacity/color, drop transform-based motion */
}
@media (hover: hover) and (pointer: fine) {
.element:hover { transform: scale(1.05); } /* gate hover motion — touch fires false hovers on tap */
}
```
```jsx
const reduce = useReducedMotion();
const closedX = reduce ? 0 : '-100%';
```
Reduced motion means fewer and gentler animations, not zero — keep transitions that aid comprehension, remove movement/position changes.
## Debugging (recommend in reviews when feel is uncertain)
- **Slow motion**: bump duration 25× or use DevTools animation inspector. Check colors crossfade cleanly, easing doesn't stop abruptly, `transform-origin` is right, coordinated properties stay in sync.
- **Frame-by-frame**: Chrome DevTools Animations panel reveals timing drift between coordinated properties.
- **Real devices** for gestures (drawers, swipe) — connect a phone, hit the dev server by IP, use Safari remote devtools.
- **Fresh eyes next day** — imperfections invisible during development surface later.
## Cohesion
Match motion to the component's personality: playful can be bouncier; a professional dashboard should be crisp and fast. Sonner feels right partly because easing, duration, design, and even the name are in harmony — slightly slower, `ease` rather than `ease-out`, to feel elegant. Opacity + height in entering/exiting lists is trial and error; there's no formula — adjust until it feels right.

View file

@ -95,12 +95,10 @@ jobs:
run: pnpm build
working-directory: surfsense_web
env:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${{ vars.HOSTED_BACKEND_URL }}
SURFSENSE_BACKEND_INTERNAL_URL: ${{ vars.HOSTED_BACKEND_URL }}
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_URL }}
NEXT_PUBLIC_ZERO_CACHE_URL: ${{ vars.NEXT_PUBLIC_ZERO_CACHE_URL }}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${{ vars.NEXT_PUBLIC_DEPLOYMENT_MODE }}
NEXT_PUBLIC_AUTH_TYPE: ${{ vars.NEXT_PUBLIC_AUTH_TYPE }}
NEXT_PUBLIC_ETL_SERVICE: ${{ vars.NEXT_PUBLIC_ETL_SERVICE }}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${{ vars.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE }}
NEXT_PUBLIC_POSTHOG_KEY: ${{ secrets.NEXT_PUBLIC_POSTHOG_KEY }}
- name: Install desktop dependencies
@ -111,9 +109,7 @@ jobs:
run: pnpm build
working-directory: surfsense_desktop
env:
HOSTED_BACKEND_URL: ${{ vars.HOSTED_BACKEND_URL }}
HOSTED_FRONTEND_URL: ${{ vars.HOSTED_FRONTEND_URL }}
GOOGLE_DESKTOP_CLIENT_ID: ${{ vars.GOOGLE_DESKTOP_CLIENT_ID }}
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
@ -144,7 +140,6 @@ jobs:
working-directory: surfsense_desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GOOGLE_DESKTOP_CLIENT_ID: ${{ vars.GOOGLE_DESKTOP_CLIENT_ID }}
WINDOWS_PUBLISHER_NAME: ${{ vars.WINDOWS_PUBLISHER_NAME }}
AZURE_CODESIGN_ENDPOINT: ${{ vars.AZURE_CODESIGN_ENDPOINT }}
AZURE_CODESIGN_ACCOUNT: ${{ vars.AZURE_CODESIGN_ACCOUNT }}

View file

@ -199,6 +199,11 @@ jobs:
build-args: |
${{ matrix.image == 'backend' && format('USE_CUDA={0}', matrix.use_cuda) || '' }}
${{ matrix.image == 'backend' && format('CUDA_EXTRA={0}', matrix.cuda_extra) || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_FASTAPI_BACKEND_URL=__NEXT_PUBLIC_FASTAPI_BACKEND_URL__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=__NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_ETL_SERVICE=__NEXT_PUBLIC_ETL_SERVICE__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_ZERO_CACHE_URL=__NEXT_PUBLIC_ZERO_CACHE_URL__' || '' }}
${{ matrix.image == 'web' && 'NEXT_PUBLIC_DEPLOYMENT_MODE=__NEXT_PUBLIC_DEPLOYMENT_MODE__' || '' }}
- name: Export digest
run: |

View file

@ -27,10 +27,9 @@ jobs:
PLAYWRIGHT_TEST_EMAIL: e2e-test@surfsense.net
PLAYWRIGHT_TEST_PASSWORD: E2eTestPassword123!
# Frontend env: Playwright's webServer (surfsense_web/playwright.config.ts)
# spawns `pnpm build && pnpm start` in CI.
# spawns `pnpm build && pnpm start` in CI; these get baked into the build.
NEXT_PUBLIC_FASTAPI_BACKEND_URL: http://localhost:8000
SURFSENSE_BACKEND_INTERNAL_URL: http://localhost:8000
AUTH_TYPE: LOCAL
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: LOCAL
# Shared secret for the test-only POST /__e2e__/auth/token endpoint.
# Must match docker-compose.e2e.yml's backend env (x-backend-env).
E2E_MINT_SECRET: e2e-mint-secret-not-for-production

5
.gitignore vendored
View file

@ -11,10 +11,6 @@ debug.log
references/
references
# Source/tests packages: exempt from the broad "references" scratch-folder ignore above.
!surfsense_backend/app/agents/chat/runtime/references/
!surfsense_backend/tests/unit/agents/chat/runtime/references/
# Playwright (E2E test artifacts)
surfsense_web/playwright/.auth/
surfsense_web/playwright-report/
@ -24,4 +20,3 @@ surfsense_web/blob-report/
content_research/
automation-design-plan.md
automation-frontend-builder-plan.md
surfsense_desktop/.env

View file

@ -1,36 +0,0 @@
---
description: Ponytail, lazy senior dev mode. Always pick the simplest solution that works.
globs:
alwaysApply: true
---
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.

15
LICENSE
View file

@ -1,18 +1,3 @@
Copyright (c) SurfSense
Portions of this software are licensed as follows:
* All content that resides under the "surfsense_backend/app/proprietary/"
directory of this repository is licensed under the Business Source License
1.1 as defined in "surfsense_backend/app/proprietary/LICENSE".
* All third-party components incorporated into the SurfSense software are
licensed under the original license provided by the owner of the applicable
component.
* Content outside of the above-mentioned directory is available under the
Apache License, Version 2.0, as defined below.
-----------------------------------------------------------------------------
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/

View file

@ -1,4 +1,4 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="SurfSense, la plataforma de inteligencia competitiva de código abierto para agentes de IA" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
@ -20,270 +20,289 @@
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
# SurfSense: Dale inteligencia competitiva a tus agentes de IA
# SurfSense
SurfSense es la **plataforma de inteligencia competitiva de código abierto para agentes de IA**. Tus agentes monitorean a la competencia, siguen los rankings y escuchan a tu mercado con datos en vivo de **Reddit, YouTube, Google Maps, Google Search y la web abierta**, a través de una única **API REST** o un **servidor MCP**. Agentes programados y activados por eventos convierten lo que encuentran en informes y alertas, y una base de conocimiento integrada mantiene cada hallazgo disponible para búsqueda con citas.
NotebookLM es una de las mejores y más útiles plataformas de IA que existen, pero una vez que comienzas a usarla regularmente también sientes sus limitaciones dejando algo que desear.
> [!NOTE]
> **📢 Una nota para nuestros usuarios de la alternativa a NotebookLM**
>
> Durante los últimos meses construimos SurfSense como el mejor agente de investigación general para tu propio conocimiento, y ese capítulo nos dio una comunidad de la que estamos genuinamente orgullosos. Herramientas agénticas como Claude, OpenCode, Hermes y OpenClaw ya han demostrado que los agentes son el futuro, y la investigación general se está convirtiendo en algo que todo agente capaz hace de fábrica. Lo que a los agentes todavía les falta son **datos de mercado en vivo y los flujos de trabajo a su alrededor**, así que ahí es donde estamos dirigiendo toda nuestra energía: convertirnos en la plataforma definitiva de agentes de inteligencia competitiva de código abierto.
>
> **Nada de lo que dependes va a desaparecer.** Tu base de conocimiento, el chat con citas, los informes, los podcasts, las presentaciones, las automatizaciones y los chats colaborativos siguen funcionando, y el autoalojamiento sigue siendo gratuito y de código abierto. Lee el anuncio completo en [nuestro changelog](https://www.surfsense.com/changelog).
1. Hay límites en la cantidad de fuentes que puedes agregar en un notebook.
2. Hay límites en la cantidad de notebooks que puedes tener.
3. No puedes tener fuentes que excedan 500,000 palabras y más de 200MB.
4. Estás bloqueado con los servicios de Google (LLMs, modelos de uso, etc.) sin opción de configurarlos.
5. Fuentes de datos externas e integraciones de servicios limitadas.
6. El agente de NotebookLM está específicamente optimizado solo para estudiar e investigar, pero puedes hacer mucho más con los datos de origen.
7. Falta de soporte multijugador.
## Tabla de contenidos
...y más.
- [Por qué los agentes necesitan SurfSense](#por-qué-los-agentes-necesitan-surfsense)
- [¿Qué puedes hacer con SurfSense?](#qué-puedes-hacer-con-surfsense)
- [Conectores de datos en vivo](#conectores-de-datos-en-vivo)
- [Inicio rápido](#inicio-rápido)
- [Todo lo demás que viene incluido](#todo-lo-demás-que-viene-incluido)
- [SurfSense vs Google NotebookLM](#surfsense-vs-google-notebooklm)
- [Hoja de ruta](#hoja-de-ruta)
- [Contribuye](#contribuye)
**SurfSense está específicamente hecho para resolver estos problemas.** SurfSense te permite:
## Por qué los agentes necesitan SurfSense
- **Controla Tu Flujo de Datos** - Mantén tus datos privados y seguros.
- **Sin Límites de Datos** - Agrega una cantidad ilimitada de fuentes y notebooks.
- **Sin Dependencia de Proveedores** - Configura cualquier modelo LLM, de imagen, TTS y STT.
- **25+ Fuentes de Datos Externas** - Agrega tus fuentes desde Google Drive, OneDrive, Dropbox, Notion y muchos otros servicios externos.
- **Soporte Multijugador en Tiempo Real** - Trabaja fácilmente con los miembros de tu equipo en un notebook compartido.
- **Automatizaciones y Agentes de IA** - Ejecuta agentes de IA según una programación o actívalos en el momento en que un documento llega a una carpeta, y luego escribe los resultados de vuelta en Notion, Slack, Linear y Drive. Crea automatizaciones sin código solo describiéndolas en el chat.
- **Aplicación de Escritorio** - Obtén asistencia de IA en cualquier aplicación con Quick Assist, General Assist, Screenshot Assist y sincronización de carpetas locales.
Pregúntale a cualquier agente capaz "¿cuánto están cobrando los competidores esta semana?" o "¿qué está diciendo Reddit sobre nosotros desde el lanzamiento?" y no tiene ningún lugar confiable donde buscar. Las APIs oficiales de las plataformas tienen límites de tasa, precios pensados para empresas grandes o directamente no existen, y la infraestructura de scraping es frágil. SurfSense cierra esa brecha:
...y más por venir.
- **Conectores nativos por plataforma**, cada uno un endpoint REST tipado que devuelve JSON estructurado. Sin ruleta de límites de tasa, sin parsear HTML.
- **Un servidor MCP** que expone cada conector como una herramienta nativa (`surfsense_reddit_scrape`, `surfsense_google_search` y más) para Claude, Cursor o cualquier framework de agentes.
- **Un arnés de agentes**, no solo datos en bruto: reintentos, salida estructurada y medición de créditos vienen integrados, así que los agentes pasan de una pregunta a un informe sin que tú construyas la infraestructura.
- **Código abierto y autoalojable**, para que tu investigación competitiva se quede en tu propia infraestructura.
## ¿Qué puedes hacer con SurfSense?
Cada caso de uso a continuación es una tarea real que el agente de SurfSense ejecuta de principio a fin hoy, con una sola instrucción o según un horario.
## Ejemplo de Agente de Video
### Flujos de trabajo multi-conector
https://github.com/user-attachments/assets/012a7ffa-6f76-4f06-9dda-7632b470057a
Encadena varios conectores en una sola ejecución del agente y recibe un único informe con citas.
- **Impacto de un lanzamiento, en todas las plataformas** — "Nuestro competidor lanzó la v2 ayer. Mide la reacción en búsquedas, Reddit y YouTube." El agente extrae SERPs, hilos de Reddit y comentarios de YouTube, y luego fusiona las tres señales en un solo informe de impacto del lanzamiento.
- **Análisis de competidores locales** — Google Maps encuentra a los jugadores, el rastreador web lee sus páginas de precios y Google Search muestra quién gana la búsqueda, todo en una sola ejecución.
- **Competidor 360, según un horario** — una automatización encadena cuatro conectores cada semana: cambios en el sitio, movimientos de ranking, sentimiento en Reddit y reacción en YouTube.
### Monitoreo de competidores
## Ejemplo de Agente de Podcast
- **Vigilancia de precios** — extrae cada plan, precio y límite de las páginas de precios de tus competidores en una sola tabla, luego vuelve a verificarlas a diario y alerta ante cualquier cambio.
- **Seguimiento de producto y changelog** — rastrea las páginas de changelog, producto y empleo de tus rivales cada lunes y recibe un informe de lo que lanzaron.
- **Monitoreo de rankings y anuncios** — sigue los rankings de Google, los anuncios pagados y las citas en AI Overviews que tu mercado realmente ve, y señala los movimientos día a día.
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
### Generación de leads B2B
- **Leads de negocios locales** — convierte una categoría y un territorio ("hamburgueserías en San José") en una lista de leads con teléfonos, sitios web, calificaciones y contactos de decisores extraídos de sus sitios.
- **Equipos y contactos** — rastrea el sitio de cualquier empresa y extrae el equipo completo con correos, redes sociales y la procedencia de cada dato, exportado a CSV.
- **Mapeo de portafolios y mercados** — mapea el portafolio de un inversor o una categoría completa, y luego enriquece cada empresa con precios y contactos.
## Cómo usar SurfSense
### Escucha de marca y mercado
### Cloud
- **Monitoreo de marca en Reddit** — escucha lo que tu mercado dice sobre ti, tus competidores y tu categoría en los hilos donde los compradores hablan con franqueza.
- **Sentimiento de audiencia en YouTube** — extrae videos, transcripciones y comentarios a escala, y luego agrupa lo que las audiencias elogian y critican.
- **Minería de intención y cambios de proveedor** — encuentra a las personas que buscan activamente una alternativa a un competidor, ordenadas por qué tan listas están para cambiarse.
1. Ve a [surfsense.com](https://www.surfsense.com) e inicia sesión.
### Investigación de mercado
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/LoginFlowGif.gif" alt="Login" /></p>
- **Investigación profunda en la web en vivo** — el agente rastrea docenas de fuentes en vivo sobre una pregunta y sintetiza una respuesta con citas, no un índice desactualizado.
- **Seguimiento de AI Overviews y GEO** — captura cuándo los AI Overviews de Google responden las búsquedas de tu mercado, y exactamente qué fuentes citan.
- **Informes y alertas con citas** — todo lo que los agentes recopilan aterriza en tu espacio de trabajo como informes y alertas con fuentes que puedes verificar.
2. Conecta tus conectores y sincroniza. Activa la sincronización periódica para mantenerlos actualizados.
### Automatiza cualquiera de estas tareas, sin código
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="Conectores" /></p>
Las automatizaciones ejecutan turnos completos de agente según un horario o en respuesta a eventos, y luego escriben los resultados en Notion, Slack, Linear y Jira. Describe el flujo de trabajo en lenguaje natural y SurfSense lo construye. Prueba con instrucciones como:
3. Mientras se indexan los datos de los conectores, sube documentos.
- "Vigila las páginas de precios de nuestros 3 principales competidores y avísame en Slack cuando cambie un plan o un precio."
- "Rastrea cada mención de nuestra marca en Reddit y YouTube y envíame un resumen diario."
- "Monitorea nuestro ranking en Google para nuestras 10 palabras clave principales y señala las caídas semana a semana."
- "Extrae las nuevas reseñas de Google Maps de nuestras ubicaciones y las de nuestros competidores cada lunes."
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="Subir Documentos" /></p>
4. Una vez que todo esté indexado, pregunta lo que quieras (Casos de uso):
**Aplicación de Escritorio** (extras nativos, además de todo lo de abajo, no un conjunto aparte)
- General Assist: abre SurfSense al instante desde cualquier aplicación con un atajo global.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/general_assist.gif" alt="General Assist" /></p>
- Quick Assist: selecciona texto en cualquier lugar y pide a la IA que lo explique, reescriba o actúe sobre él.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
- Screenshot Assist: captura cualquier región de tu pantalla y pregunta a la IA sobre lo que contiene.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/screenshot_assist.gif" alt="Screenshot Assist" /></p>
- Watch Local Folder: sincroniza automáticamente una carpeta local con tu base de conocimiento. Ideal para bóvedas de Obsidian.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/folder_watch.gif" alt="Watch Local Folder" /></p>
**Estudio de Entregables**
- AI Report Generator: genera informes de investigación con citas y expórtalos a PDF, DOCX, HTML, LaTeX, EPUB, ODT o texto plano.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="AI Report Generator" /></p>
- AI Podcast Generator: convierte cualquier documento o carpeta en un pódcast de IA con dos presentadores en menos de 20 segundos.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/PodcastGenGif.gif" alt="AI Podcast Generator" /></p>
- AI Presentation & Video Maker: crea presentaciones editables y videos narrados a partir de tus fuentes.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/video_gen_gif.gif" alt="AI Presentation and Video Maker" /></p>
- AI Image Generator: genera imágenes de alta calidad directamente desde tus chats y documentos.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ImageGenGif.gif" alt="AI Image Generator" /></p>
- AI Resume Builder: adapta tu currículum existente a cualquier descripción de empleo y supera el ATS.
Prueba indicaciones como estas:
- "Adapta mi currículum a esta descripción de empleo para superar el ATS y conseguir una entrevista."
- "Optimiza mi currículum para ATS haciendo coincidir las palabras clave de esta oferta."
- "Reescribe los puntos de mi currículum para resaltar las habilidades que pide este puesto."
- "Compara mi currículum con esta descripción de empleo y enumera las carencias a corregir."
- "Escribe una carta de presentación a juego con mi currículum y esta descripción de empleo."
**Búsqueda y Chat**
- Chat With Your PDFs & Docs: haz preguntas sobre todos tus archivos y obtén respuestas con citas en línea.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="Chat With Your PDFs and Docs" /></p>
- AI Search With Citations: búsqueda híbrida semántica y por palabras clave en toda tu base de conocimiento.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BSNCGif.gif" alt="AI Search With Citations" /></p>
- Collaborative AI Chat: trabaja en conversaciones de IA con tu equipo en tiempo real.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Collaborative AI Chat" /></p>
- Comments & Mentions: comenta y menciona a tus compañeros en cualquier mensaje de IA.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comments and Mentions" /></p>
**Conectores e Integraciones**
- Connect & Sync Your Tools: sincroniza Notion, Slack, Google Drive, Gmail, GitHub, Linear y más de 25 fuentes en un único corpus consultable.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="Connect and Sync Your Tools" /></p>
- Chat With Uploaded Files: sube PDFs, documentos de Office, imágenes y audio. Consultables al instante.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="Chat With Uploaded Files" /></p>
- Connector Write-Back: deja que el agente publique los resultados de vuelta en Notion, Slack, Linear y Drive.
Prueba indicaciones como estas:
- "Publica este resumen de investigación en mi espacio de Notion."
- "Envía estos elementos de acción de la reunión a nuestro canal de Slack."
- "Crea un ticket de Jira a partir de este informe de error."
- "Abre una incidencia en Linear a partir de esta solicitud de función."
- "Guarda este informe generado en Google Drive como un documento."
- Obsidian & Knowledge Base Sync: mantén tu bóveda de Obsidian y tu base de conocimiento personal sincronizadas.
**Automatizaciones**
- Scheduled AI Workflows: ejecuta un agente según una programación: resúmenes diarios, boletines semanales, informes recurrentes.
Prueba indicaciones como estas:
- "Envíame cada mañana un resumen diario de los nuevos documentos en mi base de conocimiento."
- "Genera un informe de estado semanal a partir de mi Slack y Gmail cada viernes."
- "Ejecuta un informe mensual de análisis de la competencia y guárdalo en mi espacio de trabajo."
- "Resume mi actividad de GitHub y Linear en una actualización diaria de standup."
- "Crea un informe de investigación semanal recurrente sobre los temas que sigo."
## Conectores de datos en vivo
- Event-Triggered Automations: lanza un agente en el momento en que un documento llega a una carpeta y publica el resultado en tus herramientas.
Prueba indicaciones como estas:
| Conector | Qué obtienen tus agentes | Más información |
|---|---|---|
| **Reddit** | Publicaciones, comentarios y flujos de subreddits sin los límites de tasa de la API oficial | [Reddit Scraper API](https://www.surfsense.com/reddit) |
| **YouTube** | Videos, transcripciones e hilos de comentarios para escuchar a tu marca y productos | [YouTube Scraper API](https://www.surfsense.com/youtube) |
| **Google Maps** | Lugares, calificaciones y reseñas para investigar competidores locales y prospectos | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | SERPs en vivo para seguimiento de posiciones y monitoreo de mercado | [Google Search API](https://www.surfsense.com/google-search) |
| **Web Crawl** (rastreo web) | Cualquier página de la web abierta como contenido limpio y estructurado | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **Conectores MCP externos** | Conecta cualquier servidor MCP a tus agentes, con OAuth de un clic para Notion, Slack, Jira y más | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
- "Cuando llegue un PDF a mi carpeta de Investigación, genera un resumen de IA con citas."
- "Cuando se añadan nuevas notas de reunión, conviértelas en actas con elementos de acción."
- "Cuando se suba una factura, extrae el proveedor, el total y la fecha de vencimiento en una tabla."
- "Cuando entre un contrato en mi carpeta Legal, señala los términos clave y las fechas de renovación."
- "Cuando se añada un currículum a Candidatos, evalúalo frente a la descripción del empleo."
La facturación es de pago por uso: los conectores facturan por elemento realmente devuelto, los rastreos por página obtenida con éxito, y las llamadas fallidas nunca se facturan. Las instalaciones autoalojadas funcionan con la facturación desactivada. Consulta los [precios](https://www.surfsense.com/pricing).
- Chat-Built Automations: describe una automatización en lenguaje sencillo y SurfSense la crea por ti.
Prueba indicaciones como estas:
## Inicio rápido
### Llama a un conector desde código
Cada conector es un endpoint REST que puedes llamar desde cualquier lenguaje con tu clave de API de SurfSense:
```bash
curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape" \
-H "Authorization: Bearer $SURFSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"search_queries": ["your brand"],
"community": "webscraping",
"sort": "top",
"time_filter": "week"
}'
```
Cada [página de conector](https://www.surfsense.com/connectors) tiene ejemplos listos para copiar y pegar en Python, JavaScript, Go, PHP, Ruby, Java y C#.
### Dale las herramientas a tus agentes vía MCP
Agrega el servidor MCP de SurfSense a Claude, Cursor o tu propio framework de agentes:
```json
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
}
```
Tu agente ahora puede llamar a cada conector como una herramienta nativa. Consulta la página del [servidor MCP de SurfSense](https://www.surfsense.com/mcp-server) para ver la lista completa de herramientas, o ejecuta el servidor localmente desde [`surfsense_mcp`](./surfsense_mcp).
### Usa la nube
Ve a [surfsense.com](https://www.surfsense.com), inicia sesión y pídele al agente datos de mercado en vivo en lenguaje natural. Las cuentas nuevas comienzan con $5 de crédito gratuito y sin suscripción.
- "Crea un agente de IA que me envíe cada mañana un resumen de las nuevas páginas de Notion."
- "Crea una automatización sin código que publique un resumen de investigación semanal en Slack."
- "Configura un tomador de notas con IA que convierta las nuevas notas de reunión en actas."
- "Crea un flujo que extraiga los elementos de acción de las notas de reunión y asigne responsables."
- "Automatiza un resumen diario por correo a partir de mi Gmail y Google Drive."
### Autoalójalo gratis
### Auto-Hospedado
Ejecuta toda la plataforma, los conectores, los agentes, las automatizaciones y el servidor MCP en tu propia infraestructura. Las instalaciones autoalojadas vienen con la facturación desactivada, así que el scraping, el rastreo y las ejecuciones de agentes solo están limitados por tu hardware y las claves de modelos que aportes.
Ejecuta SurfSense en tu propia infraestructura para control total de datos y privacidad.
**Requisitos previos:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) debe estar instalado y en ejecución.
Para Linux/macOS:
#### Para usuarios de Linux/MacOS:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
Para Windows:
#### Para usuarios de Windows:
```bash
```powershell
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
El script de instalación configura [Watchtower](https://github.com/nicholas-fedor/watchtower) automáticamente para actualizaciones automáticas diarias. Para omitirlo, agrega la opción `--no-watchtower`. Para Docker Compose, instalación manual y otras opciones de despliegue, consulta la [documentación](https://www.surfsense.com/docs/).
El script de instalación configura [Watchtower](https://github.com/nicholas-fedor/watchtower) automáticamente para actualizaciones diarias. Para omitirlo, agrega la bandera `--no-watchtower`.
## Todo lo demás que viene incluido
Para Docker Compose, instalación manual y otras opciones de despliegue, consulta la [documentación](https://www.surfsense.com/docs/).
El espacio de trabajo de investigación que convirtió a SurfSense en la alternativa de código abierto líder a NotebookLM sigue aquí, y todo lo que tus agentes recopilan aterriza en él.
### Aplicación de Escritorio
**Base de conocimiento**
SurfSense también ofrece una aplicación de escritorio que lleva la asistencia de IA a cada aplicación en tu computadora. Descárgala desde la [última versión](https://github.com/MODSetter/SurfSense/releases/latest).
- Sube PDFs, documentos de Office, imágenes y audio, o sincroniza **Google Drive, OneDrive y Dropbox**. Más de 50 formatos de archivo compatibles.
- Búsqueda híbrida semántica y de texto completo con respuestas citadas al estilo Perplexity.
- La organización de archivos con IA clasifica los documentos automáticamente por fuente, fecha y tema.
La aplicación de escritorio incluye estas potentes funciones:
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="Chatea con tus PDFs y documentos" /></p>
- **General Assist** — Lanza SurfSense al instante desde cualquier aplicación con un atajo global.
- **Quick Assist** — Selecciona texto en cualquier lugar, luego pide a la IA que lo explique, reescriba o actúe sobre él.
- **Screenshot Assist** — Selecciona una región de tu pantalla y adjúntala al chat para que las respuestas se basen en tu base de conocimiento.
- **Watch Local Folder** — Vigila una carpeta local y sincroniza automáticamente los cambios de archivos con tu base de conocimiento. **Pro tip:** Apúntalo a tu bóveda de Obsidian para mantener tus notas buscables en SurfSense.
**Estudio de entregables**
Todas las funciones operan contra tu espacio de búsqueda elegido, por lo que tus respuestas siempre están basadas en tus propios datos.
- Generador de informes con IA con exportación a PDF, DOCX, HTML, LaTeX, EPUB, ODT o texto plano.
- Podcasts de IA con dos presentadores a partir de cualquier documento o carpeta en menos de 20 segundos.
- Presentaciones de diapositivas editables, resúmenes en video narrados y generación de imágenes con IA.
### Cómo Colaborar en Tiempo Real (Beta)
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="Generador de informes con IA" /></p>
1. Ve a la página de Gestión de Miembros y crea una invitación.
**Colaboración en equipo**
<p align="center"><img src="https://github.com/user-attachments/assets/40ed7683-5aa6-48a0-a3df-00575528c392" alt="Invitar Miembros" /></p>
- Chats de IA colaborativos en tiempo real con comentarios y menciones.
- RBAC con roles de Propietario, Administrador, Editor y Visualizador.
2. El compañero de equipo se une y ese SearchSpace se comparte.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Chat de IA colaborativo" /></p>
<p align="center"><img src="https://github.com/user-attachments/assets/ea4e1057-4d2b-4fd2-9ca0-cd19286a285e" alt="Flujo de Unión por Invitación" /></p>
**Aplicación de escritorio**
3. Haz el chat compartido.
Asistencia de IA nativa en todas las aplicaciones de tu computadora. Descárgala desde la [última versión](https://github.com/MODSetter/SurfSense/releases/latest).
<p align="center"><img src="https://github.com/user-attachments/assets/17b93904-0888-4c3a-ac12-51a24a8ea26a" alt="Hacer Chat Compartido" /></p>
- **General Assist**: abre SurfSense desde cualquier aplicación con un atajo global.
- **Quick Assist**: selecciona texto en cualquier lugar y pídele a la IA que lo explique, lo reescriba o actúe sobre él.
- **Screenshot Assist**: captura cualquier región de tu pantalla y pregúntale a la IA sobre ella.
- **Watch Local Folder** (vigilar carpeta local): sincroniza automáticamente una carpeta local con tu base de conocimiento. Apúntala a tu bóveda de Obsidian para mantener tus notas disponibles para búsqueda.
4. Tu equipo ahora puede chatear en tiempo real.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Chat en Tiempo Real" /></p>
**Sin dependencia de un proveedor**
5. Agrega comentarios para etiquetar a compañeros de equipo.
- Más de 100 LLMs vía la especificación de OpenAI y LiteLLM, incluidos GPT-5.5, Claude Sonnet 5 y Gemini 3.1 Pro.
- Más de 6,000 modelos de embeddings y todos los rerankers principales.
- Soporte completo para LLMs locales y privados (vLLM, Ollama), para que tus datos sigan siendo tuyos.
## Muestra del agente de video
https://github.com/user-attachments/assets/012a7ffa-6f76-4f06-9dda-7632b470057a
## Muestra del agente de podcast
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## Cómo colaborar en tiempo real (Beta)
1. Ve a la página de administración de miembros y crea una invitación.
<p align="center"><img src="https://github.com/user-attachments/assets/40ed7683-5aa6-48a0-a3df-00575528c392" alt="Invitar miembros" /></p>
2. Un compañero de equipo se une y ese espacio de trabajo se vuelve compartido.
<p align="center"><img src="https://github.com/user-attachments/assets/ea4e1057-4d2b-4fd2-9ca0-cd19286a285e" alt="Flujo de invitación y unión" /></p>
3. Haz que un chat sea compartido y trabajen en él juntos en tiempo real, con comentarios para etiquetar a compañeros.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comentarios en tiempo real" /></p>
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comentarios en Tiempo Real" /></p>
## SurfSense vs Google NotebookLM
¿Todavía nos comparas como una alternativa a NotebookLM? Aquí tienes el desglose honesto.
| Característica | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
| **Datos de mercado en vivo para agentes** | No | Conectores de Reddit, YouTube, Google Maps, Google Search y rastreo web vía API REST y MCP |
| **Servidor MCP** | No | Cada conector expuesto como herramienta nativa de agente, más servidores MCP propios con aplicaciones OAuth de un clic |
| **Fuentes por Notebook** | 50 (gratis) a 600 (Ultra, $249.99/mes) | Ilimitadas |
| **Número de Notebooks** | 100 (gratis) a 500 (niveles de pago) | Ilimitado |
| **Límite de tamaño por fuente** | 500,000 palabras / 200MB por fuente | Sin límite |
| **Precios** | Nivel gratuito; Pro $19.99/mes, Ultra $249.99/mes | Gratuito y de código abierto para autoalojar; la nube es de pago por uso con $5 de crédito gratuito |
| **Soporte de LLM** | Solo Google Gemini | Más de 100 LLMs vía la especificación de OpenAI y LiteLLM |
| **Modelos de embeddings** | Solo Google | Más de 6,000 modelos de embeddings, todos los rerankers principales |
| **LLMs locales / privados** | No disponible | Soporte completo (vLLM, Ollama), tus datos siguen siendo tuyos |
| **Autoalojable** | No | Sí, con un solo comando de Docker o Docker Compose completo |
| **Código abierto** | No | Sí |
| **Fuentes de la base de conocimiento** | Google Drive, YouTube, sitios web | Subida de archivos, Google Drive, OneDrive, Dropbox, sincronización de carpetas locales y páginas rastreadas |
| **Formatos de archivo compatibles** | PDFs, Docs, Slides, Sheets, CSV, Word, EPUB, imágenes, URLs web, YouTube | Más de 50 formatos: documentos, imágenes, videos vía LlamaCloud, Unstructured o Docling (local) |
| **Búsqueda** | Búsqueda semántica | Híbrida semántica + texto completo con índices jerárquicos y fusión de rangos recíprocos |
| **Respuestas con citas** | Sí | Sí, respuestas citadas al estilo Perplexity |
| **Arquitectura agéntica** | No | Sí, impulsada por [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) con planificación, subagentes y acceso al sistema de archivos |
| **Automatizaciones y agentes de IA** | No | Flujos de trabajo programados, activadores por eventos y automatizaciones sin código creadas por chat con escritura en Notion, Slack, Linear y Jira |
| **Multijugador en tiempo real** | Notebooks compartidos con roles de Visualizador/Editor (sin chat en tiempo real) | RBAC con roles de Propietario / Administrador / Editor / Visualizador, chat en tiempo real e hilos de comentarios |
| **Generación de video** | Resúmenes de video cinematográficos vía Veo 3 (solo Ultra) | Disponible (NotebookLM es mejor aquí, en mejora activa) |
| **Generación de presentaciones** | Diapositivas más atractivas pero no editables | Presentaciones editables basadas en diapositivas |
| **Generación de podcasts** | Resúmenes de audio con presentadores e idiomas personalizables | Disponible con múltiples proveedores de TTS (NotebookLM es mejor aquí, en mejora activa) |
| **Aplicación de escritorio** | No | Aplicación nativa con General Assist, Quick Assist, Screenshot Assist y sincronización de carpetas locales |
| **Fuentes por Notebook** | 50 (Gratis) a 600 (Ultra, $249.99/mes) | Ilimitadas |
| **Número de Notebooks** | 100 (Gratis) a 500 (planes de pago) | Ilimitados |
| **Límite de Tamaño de Fuente** | 500,000 palabras / 200MB por fuente | Sin límite |
| **Precios** | Nivel gratuito disponible; Pro $19.99/mes, Ultra $249.99/mes | Gratuito y de código abierto, auto-hospedable en tu propia infra |
| **Soporte de LLM** | Solo Google Gemini | 100+ LLMs vía OpenAI spec y LiteLLM |
| **Modelos de Embeddings** | Solo Google | 6,000+ modelos de embeddings, todos los principales rerankers |
| **LLMs Locales / Privados** | No disponible | Soporte completo (vLLM, Ollama) - tus datos son tuyos |
| **Auto-Hospedable** | No | Sí - Docker en un solo comando o Docker Compose completo |
| **Código Abierto** | No | Sí |
| **Conectores Externos** | Google Drive, YouTube, sitios web | 27+ conectores - Motores de búsqueda, Google Drive, OneDrive, Dropbox, Slack, Teams, Jira, Notion, GitHub, Discord y [más](#fuentes-externas) |
| **Soporte de Formatos de Archivo** | PDFs, Docs, Slides, Sheets, CSV, Word, EPUB, imágenes, URLs web, YouTube | 50+ formatos - documentos, imágenes, videos vía LlamaCloud, Unstructured o Docling (local) |
| **Búsqueda** | Búsqueda semántica | Búsqueda Híbrida - Semántica + Texto completo con Índices Jerárquicos y Reciprocal Rank Fusion |
| **Respuestas con Citas** | Sí | Sí - Respuestas citadas al estilo Perplexity |
| **Arquitectura de Agentes** | No | Sí - impulsado por [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) con planificación, subagentes y acceso al sistema de archivos |
| **Multijugador en Tiempo Real** | Notebooks compartidos con roles de Visor/Editor (sin chat en tiempo real) | RBAC con roles de Propietario / Admin / Editor / Visor, chat en tiempo real e hilos de comentarios |
| **Generación de Videos** | Resúmenes en video cinemáticos vía Veo 3 (solo Ultra) | Disponible (NotebookLM es mejor aquí, mejorando activamente) |
| **Generación de Presentaciones** | Diapositivas más atractivas pero no editables | Crea presentaciones editables basadas en diapositivas |
| **Generación de Podcasts** | Resúmenes de audio con hosts e idiomas personalizables | Disponible con múltiples proveedores TTS (NotebookLM es mejor aquí, mejorando activamente) |
| **Automatizaciones y Agentes de IA** | No | Flujos de trabajo de IA programados, disparadores por eventos en documentos nuevos y automatizaciones sin código creadas por chat con escritura de vuelta a Notion, Slack, Linear y Jira |
| **Aplicación de Escritorio** | No | Aplicación nativa con General Assist, Quick Assist, Screenshot Assist y sincronización de carpetas locales |
| **Extensión de Navegador** | No | Extensión multi-navegador para guardar cualquier página web, incluyendo páginas protegidas por autenticación |
## Solicitudes de funciones y futuro
<details>
<summary><b>Lista completa de Fuentes Externas</b></summary>
<a id="fuentes-externas"></a>
**SurfSense está en desarrollo activo.** Aunque todavía no está listo para producción, puedes ayudarnos a acelerar el proceso.
Motores de Búsqueda (SearXNG, Tavily, LinkUp, Baidu Search) · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · Videos de YouTube · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, y más por venir.
</details>
## SOLICITUDES DE FUNCIONES Y FUTURO
**SurfSense está en desarrollo activo.** Aunque aún no está listo para producción, puedes ayudarnos a acelerar el proceso.
¡Únete al [Discord de SurfSense](https://discord.gg/ejRNvftDp9) y ayuda a dar forma al futuro de SurfSense!
## Hoja de ruta
## Hoja de Ruta
Mantente al día con nuestro progreso de desarrollo y las próximas funciones. Consulta nuestra hoja de ruta pública y aporta tus ideas o comentarios:
¡Mantente al día con nuestro progreso de desarrollo y próximas funcionalidades!
Consulta nuestra hoja de ruta pública y contribuye con tus ideas o comentarios:
**Discusión de la hoja de ruta:** [SurfSense 2026 Roadmap](https://github.com/MODSetter/SurfSense/discussions/565)
**Discusión de la Hoja de Ruta:** [SurfSense 2026 Roadmap](https://github.com/MODSetter/SurfSense/discussions/565)
**Tablero Kanban:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
## Contribuye
Todas las contribuciones son bienvenidas, desde estrellas y reportes de errores hasta mejoras del backend. Consulta [CONTRIBUTING.md](CONTRIBUTING.md) para comenzar.
## Contribuir
Todas las contribuciones son bienvenidas, desde estrellas y reportes de bugs hasta mejoras del backend. Consulta [CONTRIBUTING.md](CONTRIBUTING.md) para comenzar.
Gracias a todos nuestros Surfers:
@ -291,7 +310,7 @@ Gracias a todos nuestros Surfers:
<img src="https://contrib.rocks/image?repo=MODSetter/SurfSense" />
</a>
## Historial de estrellas
## Historial de Stars
<a href="https://www.star-history.com/#MODSetter/SurfSense&Date">
<picture>

View file

@ -1,4 +1,4 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="SurfSense, AI एजेंट्स के लिए ओपन सोर्स कॉम्पिटिटिव इंटेलिजेंस प्लेटफ़ॉर्म" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
@ -20,278 +20,297 @@
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
# SurfSense: अपने AI एजेंट्स को दें कॉम्पिटिटिव इंटेलिजेंस
# SurfSense
SurfSense **AI एजेंट्स के लिए ओपन सोर्स कॉम्पिटिटिव इंटेलिजेंस प्लेटफ़ॉर्म** है। आपके एजेंट प्रतिस्पर्धियों पर नज़र रखते हैं, रैंकिंग ट्रैक करते हैं, और **Reddit, YouTube, Google Maps, Google Search और ओपन वेब** से लाइव डेटा के साथ आपके बाज़ार की बात सुनते हैं, वह भी एक ही **REST API** या **MCP सर्वर** के ज़रिए। शेड्यूल्ड और इवेंट-ट्रिगर्ड एजेंट अपनी खोजों को ब्रीफ़ और अलर्ट में बदलते हैं, और एक बिल्ट-इन नॉलेज बेस हर खोज को साइटेशन के साथ खोजने योग्य बनाए रखता है
NotebookLM वहाँ उपलब्ध सबसे अच्छे और सबसे उपयोगी AI प्लेटफ़ॉर्म में से एक है, लेकिन जब आप इसे नियमित रूप से उपयोग करना शुरू करते हैं तो आप इसकी सीमाओं को भी महसूस करते हैं जो कुछ और की चाह छोड़ती हैं
> [!NOTE]
> **📢 हमारे NotebookLM-विकल्प उपयोगकर्ताओं के लिए एक सूचना**
>
> पिछले कुछ महीनों में हमने SurfSense को आपके अपने ज्ञान के लिए सबसे बेहतरीन जनरल रिसर्च एजेंट के रूप में बनाया, और उस अध्याय ने हमें एक ऐसा समुदाय दिया जिस पर हमें सचमुच गर्व है। Claude, OpenCode, Hermes और OpenClaw जैसे एजेंटिक टूल्स ने अब साबित कर दिया है कि एजेंट ही भविष्य हैं, और जनरल रिसर्च अब हर सक्षम एजेंट की एक बुनियादी क्षमता बनती जा रही है। एजेंट्स के पास अब भी जिस चीज़ की कमी है वह है **लाइव मार्केट डेटा और उसके इर्द-गिर्द के वर्कफ़्लो**, इसलिए हम अपनी पूरी ऊर्जा वहीं लगा रहे हैं: निश्चित रूप से सबसे बेहतरीन ओपन सोर्स कॉम्पिटिटिव इंटेलिजेंस एजेंट प्लेटफ़ॉर्म बनना।
>
> **आप जिस भी चीज़ पर निर्भर हैं, वह कहीं नहीं जा रही।** आपका नॉलेज बेस, साइटेशन वाली चैट, रिपोर्ट, पॉडकास्ट, प्रेज़ेंटेशन, ऑटोमेशन और सहयोगी चैट, सब पहले की तरह काम करते रहेंगे, और सेल्फ-होस्टिंग मुफ़्त और ओपन सोर्स बनी रहेगी। पूरी घोषणा [हमारे changelog](https://www.surfsense.com/changelog) पर पढ़ें।
1. एक notebook में जोड़े जा सकने वाले स्रोतों की मात्रा पर सीमाएं हैं।
2. आपके पास कितने notebooks हो सकते हैं इस पर सीमाएं हैं।
3. आपके पास ऐसे स्रोत नहीं हो सकते जो 500,000 शब्दों और 200MB से अधिक हों।
4. आप Google सेवाओं (LLMs, उपयोग मॉडल, आदि) में बंद हैं और उन्हें कॉन्फ़िगर करने का कोई विकल्प नहीं है।
5. सीमित बाहरी डेटा स्रोत और सेवा एकीकरण।
6. NotebookLM एजेंट विशेष रूप से केवल अध्ययन और शोध के लिए अनुकूलित है, लेकिन आप स्रोत डेटा के साथ और भी बहुत कुछ कर सकते हैं।
7. मल्टीप्लेयर सपोर्ट की कमी।
## विषय-सूची
...और भी बहुत कुछ।
- [एजेंट्स को SurfSense की ज़रूरत क्यों है](#एजेंट्स-को-surfsense-की-ज़रूरत-क्यों-है)
- [SurfSense से आप क्या कर सकते हैं?](#surfsense-से-आप-क्या-कर-सकते-हैं)
- [लाइव डेटा कनेक्टर](#लाइव-डेटा-कनेक्टर)
- [क्विक स्टार्ट](#क्विक-स्टार्ट)
- [बॉक्स में बाकी सब कुछ](#बॉक्स-में-बाकी-सब-कुछ)
- [SurfSense बनाम Google NotebookLM](#surfsense-बनाम-google-notebooklm)
- [रोडमैप](#रोडमैप)
- [योगदान करें](#योगदान-करें)
**SurfSense विशेष रूप से इन समस्याओं को हल करने के लिए बनाया गया है।** SurfSense आपको सक्षम बनाता है:
## एजेंट्स को SurfSense की ज़रूरत क्यों है
- **अपने डेटा प्रवाह को नियंत्रित करें** - अपने डेटा को निजी और सुरक्षित रखें।
- **कोई डेटा सीमा नहीं** - असीमित मात्रा में स्रोत और notebooks जोड़ें।
- **कोई विक्रेता लॉक-इन नहीं** - किसी भी LLM, इमेज, TTS और STT मॉडल को कॉन्फ़िगर करें।
- **25+ बाहरी डेटा स्रोत** - Google Drive, OneDrive, Dropbox, Notion और कई अन्य बाहरी सेवाओं से अपने स्रोत जोड़ें।
- **रीयल-टाइम मल्टीप्लेयर सपोर्ट** - एक साझा notebook में अपनी टीम के सदस्यों के साथ आसानी से काम करें।
- **AI ऑटोमेशन और एजेंट** - AI एजेंट को शेड्यूल पर चलाएं या जैसे ही कोई दस्तावेज़ किसी फ़ोल्डर में आए उसे ट्रिगर करें, फिर परिणाम वापस Notion, Slack, Linear और Drive में लिखें। चैट में बस वर्णन करके बिना-कोड ऑटोमेशन बनाएं।
- **डेस्कटॉप ऐप** - Quick Assist, General Assist, Screenshot Assist और लोकल फ़ोल्डर सिंक के साथ किसी भी एप्लिकेशन में AI सहायता प्राप्त करें।
किसी भी सक्षम एजेंट से पूछिए "इस हफ़्ते प्रतिस्पर्धी क्या दाम ले रहे हैं?" या "लॉन्च के बाद से Reddit पर हमारे बारे में क्या कहा जा रहा है?" और उसके पास देखने के लिए कोई भरोसेमंद जगह नहीं होती। आधिकारिक प्लेटफ़ॉर्म API या तो रेट-लिमिटेड हैं, एंटरप्राइज़ के हिसाब से महंगे हैं, या हैं ही नहीं, और स्क्रैपिंग का ढांचा नाज़ुक होता है। SurfSense इसी कमी को पूरा करता है:
- **प्लेटफ़ॉर्म-नेटिव कनेक्टर**, हर एक टाइप्ड REST एंडपॉइंट है जो स्ट्रक्चर्ड JSON लौटाता है। न रेट-लिमिट का जुआ, न HTML पार्सिंग।
- **एक MCP सर्वर** जो हर कनेक्टर को नेटिव टूल के रूप में (`surfsense_reddit_scrape`, `surfsense_google_search` और अन्य) Claude, Cursor या किसी भी एजेंट फ़्रेमवर्क को उपलब्ध कराता है।
- **एक एजेंट हार्नेस**, सिर्फ़ कच्चा डेटा नहीं: रीट्राई, स्ट्रक्चर्ड आउटपुट और क्रेडिट मीटरिंग बिल्ट-इन हैं, ताकि एजेंट सवाल से सीधे ब्रीफ़ तक पहुंच सकें और आपको ढांचा खुद न बनाना पड़े।
- **ओपन सोर्स और सेल्फ-होस्ट करने योग्य**, ताकि आपकी प्रतिस्पर्धी रिसर्च आपके अपने इन्फ्रास्ट्रक्चर पर ही रहे।
## SurfSense से आप क्या कर सकते हैं?
नीचे दिया गया हर यूज़ केस एक वास्तविक कार्य है जिसे SurfSense एजेंट आज शुरू से अंत तक पूरा करता है, एक ही प्रॉम्प्ट में या शेड्यूल पर।
### मल्टी-कनेक्टर वर्कफ़्लो
एक ही एजेंट रन में कई कनेक्टर जोड़ें और बदले में एक ही साइटेशन वाला ब्रीफ़ पाएं।
- **लॉन्च का असर, हर प्लेटफ़ॉर्म पर** — "हमारे प्रतिस्पर्धी ने कल v2 लॉन्च किया। सर्च, Reddit और YouTube पर प्रतिक्रिया मापो।" एजेंट SERPs, Reddit थ्रेड और YouTube कमेंट स्क्रैप करता है, फिर तीनों सिग्नल को एक ही लॉन्च-इम्पैक्ट ब्रीफ़ में मिला देता है।
- **स्थानीय प्रतिस्पर्धियों का विश्लेषण** — Google Maps खिलाड़ियों को ढूंढता है, वेब क्रॉलर उनके प्राइसिंग पेज पढ़ता है, और Google Search दिखाता है कि सर्च में कौन जीत रहा है, सब एक ही रन में।
- **कॉम्पिटिटर 360, शेड्यूल पर** — एक ऑटोमेशन हर हफ़्ते चार कनेक्टर जोड़ता है: साइट में बदलाव, रैंक में उतार-चढ़ाव, Reddit सेंटिमेंट और YouTube की प्रतिक्रिया।
### प्रतिस्पर्धी मॉनिटरिंग
- **प्राइसिंग वॉच** — प्रतिस्पर्धियों के प्राइसिंग पेजों से हर प्लान, कीमत और लिमिट एक ही टेबल में निकालो, फिर रोज़ाना दोबारा जांचो और किसी भी बदलाव पर अलर्ट पाओ।
- **प्रोडक्ट और changelog ट्रैकिंग** — हर सोमवार प्रतिद्वंद्वियों के changelog, प्रोडक्ट और करियर पेज क्रॉल करो और जानो कि उन्होंने क्या लॉन्च किया।
- **रैंक और विज्ञापन मॉनिटरिंग** — वे Google रैंकिंग, पेड विज्ञापन और AI Overview साइटेशन ट्रैक करो जो आपका बाज़ार वास्तव में देखता है, और दिन-प्रतिदिन के बदलावों को फ़्लैग करो।
### B2B लीड जनरेशन
- **स्थानीय बिज़नेस लीड** — किसी श्रेणी और क्षेत्र ("सैन होज़े में बर्गर की दुकानें") को फ़ोन, वेबसाइट, रेटिंग और उनकी साइटों से निकाले गए निर्णयकर्ताओं के संपर्कों वाली लीड सूची में बदलो।
- **टीम रोस्टर और संपर्क** — किसी भी कंपनी की साइट को क्रॉल करो और ईमेल, सोशल मीडिया और हर डेटा के स्रोत के साथ पूरी टीम निकालो, CSV में एक्सपोर्ट के साथ।
- **पोर्टफ़ोलियो और मार्केट मैपिंग** — किसी निवेशक का पोर्टफ़ोलियो या पूरी श्रेणी मैप करो, फिर हर कंपनी को प्राइसिंग और संपर्कों के साथ समृद्ध करो।
### ब्रांड और मार्केट लिसनिंग
- **Reddit ब्रांड मॉनिटरिंग** — उन थ्रेड्स में सुनो जहां ख़रीदार खुलकर बोलते हैं कि आपका बाज़ार आपके, आपके प्रतिस्पर्धियों और आपकी श्रेणी के बारे में क्या कह रहा है।
- **YouTube ऑडियंस सेंटिमेंट** — वीडियो, ट्रांसक्रिप्ट और कमेंट बड़े पैमाने पर खींचो, फिर पता लगाओ कि दर्शक किस चीज़ की तारीफ़ करते हैं और किस पर शिकायत।
- **विकल्प खोजने वालों और इरादे की माइनिंग** — उन लोगों को ढूंढो जो सक्रिय रूप से किसी प्रतिस्पर्धी का विकल्प खोज रहे हैं, इस आधार पर क्रमबद्ध कि वे बदलने के लिए कितने तैयार हैं।
### मार्केट रिसर्च
- **लाइव वेब पर गहन रिसर्च** — एजेंट किसी सवाल पर दर्जनों लाइव स्रोत क्रॉल करता है और साइटेशन के साथ जवाब तैयार करता है, कोई पुराना इंडेक्स नहीं।
- **AI Overview और GEO ट्रैकिंग** — कैप्चर करो कि Google के AI Overviews आपके बाज़ार की क्वेरी का जवाब कब देते हैं, और वे ठीक-ठीक किन स्रोतों को साइट करते हैं।
- **साइटेशन वाले ब्रीफ़ और अलर्ट** — एजेंट जो कुछ भी इकट्ठा करते हैं वह आपके वर्कस्पेस में ब्रीफ़ और अलर्ट के रूप में पहुंचता है, ऐसे स्रोतों के साथ जिन्हें आप जांच सकते हैं।
### इनमें से कुछ भी ऑटोमेट करें, बिना कोड के
ऑटोमेशन शेड्यूल पर या इवेंट के जवाब में पूरे एजेंट टर्न चलाते हैं, फिर नतीजे Notion, Slack, Linear और Jira में वापस लिख देते हैं। वर्कफ़्लो को सीधी-सादी भाषा में बताइए और SurfSense उसे बना देगा। ये प्रॉम्प्ट आज़माएं:
- "हमारे टॉप 3 प्रतिस्पर्धियों के प्राइसिंग पेज पर नज़र रखो और जब कोई प्लान या कीमत बदले तो मुझे Slack में अलर्ट करो।"
- "Reddit और YouTube पर हमारे ब्रांड के हर उल्लेख को ट्रैक करो और मुझे रोज़ाना डाइजेस्ट भेजो।"
- "हमारे टॉप 10 कीवर्ड के लिए हमारी Google रैंकिंग मॉनिटर करो और हफ़्ते-दर-हफ़्ते गिरावट को फ़्लैग करो।"
- "हर सोमवार हमारी लोकेशनों और हमारे प्रतिस्पर्धियों की नई Google Maps रिव्यू खींचो।"
- "हर महीने एक प्रतिस्पर्धी विश्लेषण रिपोर्ट चलाओ और उसे मेरे वर्कस्पेस में सेव करो।"
## लाइव डेटा कनेक्टर
| कनेक्टर | आपके एजेंट्स को क्या मिलता है | और जानें |
|---|---|---|
| **Reddit** | आधिकारिक API की रेट लिमिट के बिना पोस्ट, कमेंट और सबरेडिट स्ट्रीम | [Reddit Scraper API](https://www.surfsense.com/reddit) |
| **YouTube** | ब्रांड और प्रोडक्ट लिसनिंग के लिए वीडियो, ट्रांसक्रिप्ट और कमेंट थ्रेड | [YouTube Scraper API](https://www.surfsense.com/youtube) |
| **Google Maps** | स्थानीय प्रतिस्पर्धी और लीड रिसर्च के लिए स्थान, रेटिंग और रिव्यू | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | रैंक ट्रैकिंग और मार्केट मॉनिटरिंग के लिए लाइव SERP | [Google Search API](https://www.surfsense.com/google-search) |
| **Web Crawl** | ओपन वेब का कोई भी पेज साफ़-सुथरे, स्ट्रक्चर्ड कंटेंट के रूप में | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **External MCP Connectors** | कोई भी MCP सर्वर अपने एजेंट्स से जोड़ें, Notion, Slack, Jira और अन्य के लिए वन-क्लिक OAuth के साथ | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
बिलिंग पे-एज़-यू-गो है: कनेक्टर सिर्फ़ वास्तव में लौटाए गए हर आइटम पर बिल करते हैं, क्रॉल सफलतापूर्वक फ़ेच किए गए हर पेज पर, और असफल कॉल कभी बिल नहीं होतीं। सेल्फ-होस्टेड इंस्टॉल बिलिंग बंद रखकर चलते हैं। देखें [pricing](https://www.surfsense.com/pricing)।
## क्विक स्टार्ट
### कोड से कनेक्टर कॉल करें
हर कनेक्टर एक REST एंडपॉइंट है जिसे आप अपनी SurfSense API कुंजी के साथ किसी भी भाषा से कॉल कर सकते हैं:
```bash
curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape" \
-H "Authorization: Bearer $SURFSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"search_queries": ["your brand"],
"community": "webscraping",
"sort": "top",
"time_filter": "week"
}'
```
हर [कनेक्टर पेज](https://www.surfsense.com/connectors) पर Python, JavaScript, Go, PHP, Ruby, Java और C# में कॉपी-पेस्ट उदाहरण मौजूद हैं।
### MCP के ज़रिए ये टूल्स अपने एजेंट्स को दें
SurfSense MCP सर्वर को Claude, Cursor या अपने एजेंट फ़्रेमवर्क में जोड़ें:
```json
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
}
```
अब आपका एजेंट हर कनेक्टर को नेटिव टूल के रूप में कॉल कर सकता है। टूल्स की पूरी सूची के लिए [SurfSense MCP सर्वर](https://www.surfsense.com/mcp-server) पेज देखें, या [`surfsense_mcp`](./surfsense_mcp) से सर्वर को लोकली चलाएँ।
### क्लाउड इस्तेमाल करें
[surfsense.com](https://www.surfsense.com) पर जाएं, लॉग इन करें, और एजेंट से सीधी-सादी भाषा में लाइव मार्केट डेटा मांगें। नए अकाउंट $5 के मुफ़्त क्रेडिट के साथ शुरू होते हैं, बिना किसी सब्सक्रिप्शन के।
...और भी बहुत कुछ आने वाला है।
### मुफ़्त में सेल्फ-होस्ट करें
पूरा प्लेटफ़ॉर्म, कनेक्टर, एजेंट, ऑटोमेशन और MCP सर्वर अपने इन्फ्रास्ट्रक्चर पर चलाएं। सेल्फ-होस्टेड इंस्टॉल बिलिंग बंद के साथ आते हैं, इसलिए स्क्रैपिंग, क्रॉलिंग और एजेंट रन की सीमा सिर्फ़ आपके हार्डवेयर और आपके द्वारा लाई गई मॉडल कुंजियों पर निर्भर करती है।
**आवश्यकताएं:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) इंस्टॉल होना और चल रहा होना चाहिए।
Linux/macOS के लिए:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
Windows के लिए:
```bash
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
इंस्टॉल स्क्रिप्ट दैनिक ऑटो-अपडेट के लिए [Watchtower](https://github.com/nicholas-fedor/watchtower) अपने आप सेट कर देती है। इसे छोड़ने के लिए `--no-watchtower` फ़्लैग जोड़ें। Docker Compose, मैनुअल इंस्टॉलेशन और अन्य डिप्लॉयमेंट विकल्पों के लिए [docs](https://www.surfsense.com/docs/) देखें।
## बॉक्स में बाकी सब कुछ
जिस रिसर्च वर्कस्पेस ने SurfSense को अग्रणी ओपन सोर्स NotebookLM विकल्प बनाया, वह अब भी यहीं है, और आपके एजेंट जो कुछ भी इकट्ठा करते हैं वह सब इसी में पहुंचता है।
**नॉलेज बेस**
- PDF, Office दस्तावेज़, इमेज और ऑडियो अपलोड करें, या **Google Drive, OneDrive और Dropbox** सिंक करें। 50+ फ़ाइल फ़ॉर्मैट समर्थित हैं।
- हाइब्रिड सिमेंटिक और फ़ुल-टेक्स्ट सर्च, Perplexity-शैली के साइटेड जवाबों के साथ।
- AI फ़ाइल सॉर्टिंग दस्तावेज़ों को स्रोत, तारीख़ और विषय के अनुसार अपने आप व्यवस्थित करती है।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="अपनी PDF और दस्तावेज़ों से चैट करें" /></p>
**डिलीवरेबल स्टूडियो**
- AI रिपोर्ट जनरेटर, PDF, DOCX, HTML, LaTeX, EPUB, ODT या सादे टेक्स्ट में एक्सपोर्ट के साथ।
- किसी भी दस्तावेज़ या फ़ोल्डर से 20 सेकंड से कम में दो-होस्ट वाले AI पॉडकास्ट।
- संपादन योग्य स्लाइड डेक, नैरेटेड वीडियो ओवरव्यू और AI इमेज जनरेशन।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="AI रिपोर्ट जनरेटर" /></p>
**टीम सहयोग**
- कमेंट और मेंशन के साथ रीयल-टाइम सहयोगी AI चैट।
- Owner, Admin, Editor और Viewer भूमिकाओं के साथ RBAC।
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="सहयोगी AI चैट" /></p>
**डेस्कटॉप ऐप**
आपके कंप्यूटर के हर एप्लिकेशन में नेटिव AI सहायता। [latest release](https://github.com/MODSetter/SurfSense/releases/latest) से डाउनलोड करें।
- **General Assist**: ग्लोबल शॉर्टकट से किसी भी ऐप से SurfSense लॉन्च करें।
- **Quick Assist**: कहीं भी टेक्स्ट चुनें, फिर AI से उसे समझाने, फिर से लिखने या उस पर कार्रवाई करने को कहें।
- **Screenshot Assist**: अपनी स्क्रीन का कोई भी हिस्सा कैप्चर करें और AI से उसके बारे में पूछें।
- **Watch Local Folder**: किसी लोकल फ़ोल्डर को अपने नॉलेज बेस से ऑटो-सिंक करें। इसे अपने Obsidian वॉल्ट पर पॉइंट करें ताकि आपके नोट्स खोजने योग्य बने रहें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
**कोई वेंडर लॉक-इन नहीं**
- OpenAI स्पेक और LiteLLM के ज़रिए 100+ LLM, जिनमें GPT-5.5, Claude Sonnet 5 और Gemini 3.1 Pro शामिल हैं।
- 6,000+ एम्बेडिंग मॉडल और सभी प्रमुख रीरैंकर।
- पूर्ण लोकल और प्राइवेट LLM समर्थन (vLLM, Ollama), ताकि आपका डेटा आपका ही रहे।
## वीडियो एजेंट नमूना
https://github.com/user-attachments/assets/012a7ffa-6f76-4f06-9dda-7632b470057a
## पॉडकास्ट एजेंट नमूना
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## रीयल टाइम में सहयोग कैसे करें (बीटा)
1. Manage Members पेज पर जाएं और एक इनवाइट बनाएं।
## SurfSense का उपयोग कैसे करें
### Cloud
1. [surfsense.com](https://www.surfsense.com) पर जाएं और लॉगिन करें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/LoginFlowGif.gif" alt="लॉगिन" /></p>
2. अपने कनेक्टर जोड़ें और सिंक करें। कनेक्टर्स को अपडेट रखने के लिए आवधिक सिंकिंग सक्षम करें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="कनेक्टर्स" /></p>
3. जब तक कनेक्टर्स का डेटा इंडेक्स हो रहा है, दस्तावेज़ अपलोड करें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="दस्तावेज़ अपलोड करें" /></p>
4. सब कुछ इंडेक्स हो जाने के बाद, कुछ भी पूछें (उपयोग के मामले):
**डेस्कटॉप ऐप** (नीचे दी गई सभी सुविधाओं के अलावा नेटिव एक्स्ट्रा, कोई अलग सेट नहीं)
- General Assist: किसी भी ऐप्लिकेशन से ग्लोबल शॉर्टकट के ज़रिए SurfSense तुरंत खोलें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/general_assist.gif" alt="General Assist" /></p>
- Quick Assist: कहीं भी टेक्स्ट चुनें और AI से उसे समझाने, दोबारा लिखने या उस पर कार्रवाई करने को कहें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
- Screenshot Assist: अपनी स्क्रीन का कोई भी हिस्सा कैप्चर करें और AI से उसमें मौजूद चीज़ों के बारे में पूछें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/screenshot_assist.gif" alt="Screenshot Assist" /></p>
- Watch Local Folder: किसी लोकल फ़ोल्डर को अपने नॉलेज बेस के साथ अपने-आप सिंक करें। Obsidian vaults के लिए बढ़िया।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/folder_watch.gif" alt="Watch Local Folder" /></p>
**डिलीवरेबल स्टूडियो**
- AI Report Generator: उद्धरण सहित रिसर्च रिपोर्ट बनाएं और PDF, DOCX, HTML, LaTeX, EPUB, ODT या सादे टेक्स्ट में एक्सपोर्ट करें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="AI Report Generator" /></p>
- AI Podcast Generator: किसी भी दस्तावेज़ या फ़ोल्डर को 20 सेकंड से भी कम में दो-होस्ट वाले AI पॉडकास्ट में बदलें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/PodcastGenGif.gif" alt="AI Podcast Generator" /></p>
- AI Presentation & Video Maker: अपने स्रोतों से एडिट करने योग्य स्लाइड डेक और नैरेटेड वीडियो बनाएं।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/video_gen_gif.gif" alt="AI Presentation and Video Maker" /></p>
- AI Image Generator: अपनी चैट और दस्तावेज़ों से सीधे उच्च-गुणवत्ता वाली इमेज बनाएं।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ImageGenGif.gif" alt="AI Image Generator" /></p>
- AI Resume Builder: अपने मौजूदा रिज़्यूमे को किसी भी जॉब डिस्क्रिप्शन के अनुसार ढालें और ATS को पार करें।
इस तरह के प्रॉम्प्ट आज़माएं:
- "मेरे रिज़्यूमे को इस जॉब डिस्क्रिप्शन के अनुसार ढालें ताकि वह ATS पार करे और इंटरव्यू दिलाए।"
- "इस जॉब पोस्टिंग के कीवर्ड्स से मिलान करके मेरे रिज़्यूमे को ATS के लिए ऑप्टिमाइज़ करें।"
- "इस भूमिका के लिए ज़रूरी स्किल्स को उभारने के लिए मेरे रिज़्यूमे के बुलेट पॉइंट फिर से लिखें।"
- "मेरे रिज़्यूमे की तुलना इस जॉब डिस्क्रिप्शन से करें और सुधारने योग्य कमियों की सूची दें।"
- "मेरे रिज़्यूमे और इस जॉब डिस्क्रिप्शन से मेल खाता एक कवर लेटर लिखें।"
**सर्च और चैट**
- Chat With Your PDFs & Docs: अपनी सभी फ़ाइलों पर सवाल पूछें और इनलाइन उद्धरणों के साथ जवाब पाएं।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="Chat With Your PDFs and Docs" /></p>
- AI Search With Citations: अपने पूरे नॉलेज बेस में हाइब्रिड सेमांटिक और कीवर्ड सर्च।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BSNCGif.gif" alt="AI Search With Citations" /></p>
- Collaborative AI Chat: अपनी टीम के साथ रियल टाइम में AI बातचीत पर काम करें।
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Collaborative AI Chat" /></p>
- Comments & Mentions: किसी भी AI संदेश पर टिप्पणी करें और टीम के साथियों को टैग करें।
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comments and Mentions" /></p>
**कनेक्टर्स और इंटीग्रेशन**
- Connect & Sync Your Tools: Notion, Slack, Google Drive, Gmail, GitHub, Linear और 25+ स्रोतों को एक खोजने योग्य कॉर्पस में सिंक करें।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="Connect and Sync Your Tools" /></p>
- Chat With Uploaded Files: PDF, Office दस्तावेज़, इमेज और ऑडियो अपलोड करें। तुरंत खोजने योग्य।
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="Chat With Uploaded Files" /></p>
- Connector Write-Back: एजेंट को परिणाम वापस Notion, Slack, Linear और Drive में पोस्ट करने दें।
इस तरह के प्रॉम्प्ट आज़माएं:
- "इस रिसर्च सारांश को मेरे Notion वर्कस्पेस में पोस्ट करें।"
- "इन मीटिंग एक्शन आइटम्स को हमारे टीम Slack चैनल पर भेजें।"
- "इस बग रिपोर्ट से एक Jira टिकट बनाएं।"
- "इस फ़ीचर अनुरोध से Linear में एक इश्यू खोलें।"
- "इस जनरेट की गई रिपोर्ट को Google Drive में एक डॉक के रूप में सेव करें।"
- Obsidian & Knowledge Base Sync: अपने Obsidian vault और व्यक्तिगत नॉलेज बेस को सिंक रखें।
**ऑटोमेशन**
- Scheduled AI Workflows: किसी एजेंट को शेड्यूल पर चलाएं: रोज़ाना ब्रीफ़, साप्ताहिक डाइजेस्ट, आवर्ती रिपोर्ट।
इस तरह के प्रॉम्प्ट आज़माएं:
- "हर सुबह मेरे नॉलेज बेस में जुड़े नए दस्तावेज़ों का रोज़ाना ब्रीफ़ मुझे ईमेल करें।"
- "हर शुक्रवार मेरे Slack और Gmail से एक साप्ताहिक स्टेटस रिपोर्ट बनाएं।"
- "एक मासिक प्रतिस्पर्धी विश्लेषण रिपोर्ट चलाएं और उसे मेरे वर्कस्पेस में सेव करें।"
- "मेरी GitHub और Linear गतिविधि को एक रोज़ाना standup अपडेट में सारांशित करें।"
- "मैं जिन विषयों को ट्रैक करता हूं उन पर एक आवर्ती साप्ताहिक रिसर्च रिपोर्ट बनाएं।"
- Event-Triggered Automations: जैसे ही कोई दस्तावेज़ किसी फ़ोल्डर में आता है, एजेंट को चलाएं और परिणाम अपने टूल में पोस्ट करें।
इस तरह के प्रॉम्प्ट आज़माएं:
- "जब मेरे Research फ़ोल्डर में कोई PDF आए, तो उद्धरण सहित एक AI सारांश बनाएं।"
- "जब नई मीटिंग नोट्स जुड़ें, तो उन्हें एक्शन आइटम्स के साथ मीटिंग मिनट्स में बदलें।"
- "जब कोई इनवॉइस अपलोड हो, तो विक्रेता, कुल राशि और देय तिथि को एक तालिका में निकालें।"
- "जब मेरे Legal फ़ोल्डर में कोई अनुबंध आए, तो मुख्य शर्तों और नवीनीकरण तिथियों को चिह्नित करें।"
- "जब Candidates में कोई रिज़्यूमे जुड़े, तो उसे जॉब डिस्क्रिप्शन के विरुद्ध स्क्रीन करें।"
- Chat-Built Automations: सरल भाषा में किसी ऑटोमेशन का वर्णन करें और SurfSense उसे आपके लिए बना देगा।
इस तरह के प्रॉम्प्ट आज़माएं:
- "एक AI एजेंट बनाएं जो हर सुबह नई Notion पेजों का सारांश मुझे ईमेल करे।"
- "एक नो-कोड ऑटोमेशन बनाएं जो हर सप्ताह एक रिसर्च डाइजेस्ट Slack पर पोस्ट करे।"
- "एक AI नोट-टेकर सेट करें जो नई मीटिंग नोट्स को मिनट्स में बदल दे।"
- "एक वर्कफ़्लो बनाएं जो मीटिंग नोट्स से एक्शन आइटम्स निकाले और ज़िम्मेदार सौंपे।"
- "मेरे Gmail और Google Drive से एक रोज़ाना ईमेल ब्रीफ़ को ऑटोमेट करें।"
### सेल्फ-होस्टेड
पूर्ण डेटा नियंत्रण और गोपनीयता के लिए SurfSense को अपने स्वयं के बुनियादी ढांचे पर चलाएं।
**आवश्यकताएँ:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) इंस्टॉल और चालू होना चाहिए।
#### Linux/MacOS उपयोगकर्ताओं के लिए:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
#### Windows उपयोगकर्ताओं के लिए:
```powershell
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
इंस्टॉल स्क्रिप्ट दैनिक ऑटो-अपडेट के लिए स्वचालित रूप से [Watchtower](https://github.com/nicholas-fedor/watchtower) सेटअप करती है। इसे छोड़ने के लिए, `--no-watchtower` फ्लैग जोड़ें।
Docker Compose, मैनुअल इंस्टॉलेशन और अन्य डिप्लॉयमेंट विकल्पों के लिए, [डॉक्स](https://www.surfsense.com/docs/) देखें।
### डेस्कटॉप ऐप
SurfSense एक डेस्कटॉप ऐप भी प्रदान करता है जो आपके कंप्यूटर पर हर एप्लिकेशन में AI सहायता लाता है। इसे [नवीनतम रिलीज़](https://github.com/MODSetter/SurfSense/releases/latest) से डाउनलोड करें।
डेस्कटॉप ऐप में ये शक्तिशाली सुविधाएं शामिल हैं:
- **General Assist** — एक ग्लोबल शॉर्टकट से किसी भी एप्लिकेशन से तुरंत SurfSense लॉन्च करें।
- **Quick Assist** — कहीं भी टेक्स्ट चुनें, फिर AI से समझाने, फिर से लिखने या उस पर कार्रवाई करने को कहें।
- **Screenshot Assist** — स्क्रीन पर एक क्षेत्र चुनें और उसे चैट में जोड़ें, ताकि उत्तर आपकी नॉलेज बेस पर आधारित रहें।
- **Watch Local Folder** — एक लोकल फ़ोल्डर को वॉच करें और फ़ाइल परिवर्तनों को स्वचालित रूप से अपनी नॉलेज बेस में सिंक करें। **Pro tip:** इसे अपने Obsidian vault पर पॉइंट करें ताकि आपके नोट्स SurfSense में सर्च करने योग्य रहें।
सभी सुविधाएं आपके चुने हुए सर्च स्पेस पर काम करती हैं, ताकि आपके उत्तर हमेशा आपके अपने डेटा पर आधारित हों।
### रीयल-टाइम सहयोग कैसे करें (बीटा)
1. सदस्य प्रबंधन पेज पर जाएं और एक आमंत्रण बनाएं।
<p align="center"><img src="https://github.com/user-attachments/assets/40ed7683-5aa6-48a0-a3df-00575528c392" alt="सदस्यों को आमंत्रित करें" /></p>
2. कोई साथी जुड़ता है और वह वर्कस्पेस साझा हो जाता है।
2. टीममेट जुड़ता है और वह SearchSpace साझा हो जाता है।
<p align="center"><img src="https://github.com/user-attachments/assets/ea4e1057-4d2b-4fd2-9ca0-cd19286a285e" alt="इनवाइट जॉइन फ़्लो" /></p>
<p align="center"><img src="https://github.com/user-attachments/assets/ea4e1057-4d2b-4fd2-9ca0-cd19286a285e" alt="आमंत्रण स्वीकार प्रवाह" /></p>
3. किसी चैट को साझा करें और उसमें रीयल टाइम में साथ काम करें, साथियों को टैग करने के लिए कमेंट के साथ।
3. चैट को साझा करें।
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="रीयलटाइम कमेंट" /></p>
<p align="center"><img src="https://github.com/user-attachments/assets/17b93904-0888-4c3a-ac12-51a24a8ea26a" alt="चैट साझा करें" /></p>
## SurfSense बनाम Google NotebookLM
4. आपकी टीम अब रीयल-टाइम में चैट कर सकती है।
अब भी हमें NotebookLM विकल्प के तौर पर तौल रहे हैं? यह रहा ईमानदार तुलनात्मक ब्यौरा।
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="रीयल-टाइम चैट" /></p>
| फ़ीचर | Google NotebookLM | SurfSense |
5. टीममेट्स को टैग करने के लिए कमेंट जोड़ें।
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="रीयल-टाइम कमेंट्स" /></p>
## SurfSense vs Google NotebookLM
| विशेषता | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
| **एजेंट्स के लिए लाइव मार्केट डेटा** | नहीं | REST API और MCP के ज़रिए Reddit, YouTube, Google Maps, Google Search और वेब क्रॉल कनेक्टर |
| **MCP सर्वर** | नहीं | हर कनेक्टर नेटिव एजेंट टूल के रूप में उपलब्ध, साथ ही वन-क्लिक OAuth ऐप्स के साथ अपने MCP सर्वर लाने की सुविधा |
| **प्रति नोटबुक स्रोत** | 50 (Free) से 600 (Ultra, $249.99/माह) | असीमित |
| **नोटबुक की संख्या** | 100 (Free) से 500 (सशुल्क टियर) | असीमित |
| **प्रति Notebook स्रोत** | 50 (मुफ़्त) से 600 (Ultra, $249.99/माह) | असीमित |
| **Notebooks की संख्या** | 100 (मुफ़्त) से 500 (सशुल्क योजनाएं) | असीमित |
| **स्रोत आकार सीमा** | 500,000 शब्द / 200MB प्रति स्रोत | कोई सीमा नहीं |
| **मूल्य निर्धारण** | Free टियर; Pro $19.99/माह, Ultra $249.99/माह | सेल्फ-होस्ट के लिए मुफ़्त और ओपन सोर्स; क्लाउड पे-एज़-यू-गो है, $5 मुफ़्त क्रेडिट के साथ |
| **LLM समर्थन** | केवल Google Gemini | OpenAI स्पेक और LiteLLM के ज़रिए 100+ LLM |
| **एम्बेडिंग मॉडल** | केवल Google | 6,000+ एम्बेडिंग मॉडल, सभी प्रमुख रीरैंकर |
| **लोकल / प्राइवेट LLM** | उपलब्ध नहीं | पूर्ण समर्थन (vLLM, Ollama), आपका डेटा आपका ही रहता है |
| **सेल्फ-होस्ट करने योग्य** | नहीं | हां, Docker वन-लाइनर या पूर्ण Docker Compose |
| **ओपन सोर्स** | नहीं | हां |
| **नॉलेज बेस स्रोत** | Google Drive, YouTube, वेबसाइट | फ़ाइल अपलोड, Google Drive, OneDrive, Dropbox, लोकल फ़ोल्डर सिंक और क्रॉल किए गए पेज |
| **फ़ाइल फ़ॉर्मैट समर्थन** | PDF, Docs, Slides, Sheets, CSV, Word, EPUB, इमेज, वेब URL, YouTube | 50+ फ़ॉर्मैट: LlamaCloud, Unstructured या Docling (लोकल) के ज़रिए दस्तावेज़, इमेज, वीडियो |
| **सर्च** | सिमेंटिक सर्च | हाइरार्किकल इंडेक्स और रेसिप्रोकल रैंक फ़्यूज़न के साथ हाइब्रिड सिमेंटिक + फ़ुल-टेक्स्ट |
| **साइटेड जवाब** | हां | हां, Perplexity-शैली के साइटेड जवाब |
| **एजेंटिक आर्किटेक्चर** | नहीं | हां, [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) द्वारा संचालित, प्लानिंग, सबएजेंट और फ़ाइल सिस्टम एक्सेस के साथ |
| **AI ऑटोमेशन और एजेंट** | नहीं | शेड्यूल्ड वर्कफ़्लो, इवेंट ट्रिगर और चैट से बने नो-कोड ऑटोमेशन, Notion, Slack, Linear और Jira में राइट-बैक के साथ |
| **रीयल-टाइम मल्टीप्लेयर** | Viewer/Editor भूमिकाओं के साथ साझा नोटबुक (रीयल-टाइम चैट नहीं) | Owner / Admin / Editor / Viewer भूमिकाओं के साथ RBAC, रीयल-टाइम चैट और कमेंट थ्रेड |
| **वीडियो जनरेशन** | Veo 3 के ज़रिए सिनेमैटिक वीडियो ओवरव्यू (केवल Ultra) | उपलब्ध (यहां NotebookLM बेहतर है, सक्रिय रूप से सुधार जारी) |
| **प्रेज़ेंटेशन जनरेशन** | दिखने में बेहतर स्लाइड, लेकिन संपादन योग्य नहीं | संपादन योग्य, स्लाइड-आधारित प्रेज़ेंटेशन |
| **पॉडकास्ट जनरेशन** | कस्टमाइज़ करने योग्य होस्ट और भाषाओं के साथ ऑडियो ओवरव्यू | कई TTS प्रोवाइडर के साथ उपलब्ध (यहां NotebookLM बेहतर है, सक्रिय रूप से सुधार जारी) |
| **मूल्य निर्धारण** | मुफ़्त स्तर उपलब्ध; Pro $19.99/माह, Ultra $249.99/माह | मुफ़्त और ओपन सोर्स, अपनी इंफ्रा पर सेल्फ-होस्ट करें |
| **LLM सपोर्ट** | केवल Google Gemini | 100+ LLMs OpenAI spec और LiteLLM के माध्यम से |
| **एम्बेडिंग मॉडल** | केवल Google | 6,000+ एम्बेडिंग मॉडल, सभी प्रमुख रीरैंकर्स |
| **लोकल / प्राइवेट LLMs** | उपलब्ध नहीं | पूर्ण सपोर्ट (vLLM, Ollama) - आपका डेटा आपका रहता है |
| **सेल्फ-होस्ट करने योग्य** | नहीं | हाँ - Docker एक कमांड या पूर्ण Docker Compose |
| **ओपन सोर्स** | नहीं | हा |
| **बाहरी कनेक्टर्स** | Google Drive, YouTube, वेबसाइटें | 27+ कनेक्टर्स - सर्च इंजन, Google Drive, OneDrive, Dropbox, Slack, Teams, Jira, Notion, GitHub, Discord और [अधिक](#बाहरी-स्रोत) |
| **फ़ाइल फ़ॉर्मेट सपोर्ट** | PDFs, Docs, Slides, Sheets, CSV, Word, EPUB, इमेज, वेब URLs, YouTube | 50+ फ़ॉर्मेट - दस्तावेज़, इमेज, वीडियो LlamaCloud, Unstructured या Docling (लोकल) के माध्यम से |
| **सर्च** | सिमैंटिक सर्च | हाइब्रिड सर्च - हायरार्किकल इंडाइसेस और Reciprocal Rank Fusion के साथ सिमैंटिक + फुल टेक्स्ट |
| **उद्धृत उत्तर** | हाँ | हाँ - Perplexity शैली के उद्धृत उत्तर |
| **एजेंट आर्किटेक्चर** | नहीं | हाँ - [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) द्वारा संचालित, योजना, सब-एजेंट और फ़ाइल सिस्टम एक्सेस |
| **रीयल-टाइम मल्टीप्लेयर** | दर्शक/संपादक भूमिकाओं के साथ साझा notebooks (कोई रीयल-टाइम चैट नहीं) | मालिक / एडमिन / संपादक / दर्शक भूमिकाओं के साथ RBAC, रीयल-टाइम चैट और कमेंट थ्रेड |
| **वीडियो जनरेशन** | Veo 3 के माध्यम से सिनेमैटिक वीडियो ओवरव्यू (केवल Ultra) | उपलब्ध (NotebookLM यहाँ बेहतर है, सक्रिय रूप से सुधार हो रहा है) |
| **प्रेजेंटेशन जनरेशन** | बेहतर दिखने वाली स्लाइड्स लेकिन संपादन योग्य नहीं | संपादन योग्य, स्लाइड आधारित प्रेजेंटेशन बनाएं |
| **पॉडकास्ट जनरेशन** | कस्टमाइज़ेबल होस्ट और भाषाओं के साथ ऑडियो ओवरव्यू | कई TTS प्रदाताओं के साथ उपलब्ध (NotebookLM यहाँ बेहतर है, सक्रिय रूप से सुधार हो रहा है) |
| **AI ऑटोमेशन और एजेंट** | नहीं | शेड्यूल किए गए AI वर्कफ़्लो, नए दस्तावेज़ों पर इवेंट ट्रिगर, और चैट से बने बिना-कोड ऑटोमेशन, Notion, Slack, Linear और Jira में कनेक्टर राइट-बैक के साथ |
| **डेस्कटॉप ऐप** | नहीं | General Assist, Quick Assist, Screenshot Assist और लोकल फ़ोल्डर सिंक के साथ नेटिव ऐप |
| **ब्राउज़र एक्सटेंशन** | नहीं | किसी भी वेबपेज को सहेजने के लिए क्रॉस-ब्राउज़र एक्सटेंशन, प्रमाणीकरण सुरक्षित पेज सहित |
<details>
<summary><b>बाहरी स्रोतों की पूरी सूची</b></summary>
<a id="बाहरी-स्रोत"></a>
सर्च इंजन (SearXNG, Tavily, LinkUp, Baidu Search) · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube वीडियो · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, और भी बहुत कुछ आने वाला है।
</details>
## फ़ीचर अनुरोध और भविष्य
**SurfSense सक्रिय रूप से विकसित किया जा रहा है।** हालांकि यह अभी प्रोडक्शन-रेडी नहीं है, आप इस प्रक्रिया को तेज़ करने में हमारी मदद कर सकते हैं।
[SurfSense Discord](https://discord.gg/ejRNvftDp9) से जुड़ें और SurfSense का भविष्य गढ़ने में मदद करें!
**SurfSense सक्रिय रूप से विकसित किया जा रहा है।** हालांकि यह अभी प्रोडक्शन-रेडी नहीं है, आप प्रक्रिया को तेज़ करने में हमारी मदद कर सकते हैं।
[SurfSense Discord](https://discord.gg/ejRNvftDp9) में शामिल हों और SurfSense के भविष्य को आकार देने में मदद करें!
## रोडमैप
हमारी विकास प्रगति और आने वाले फ़ीचर्स से अपडेट रहें। हमारा सार्वजनिक रोडमैप देखें और अपने विचार या फ़ीडबैक साझा करें:
हमारे विकास की प्रगति और आने वाली सुविधाओं से अपडेट रहें!
हमारा सार्वजनिक रोडमैप देखें और अपने विचार या फ़ीडबैक दें:
**रोडमैप चर्चा:** [SurfSense 2026 Roadmap](https://github.com/MODSetter/SurfSense/discussions/565)
**कानबान बोर्ड:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
**कानबन बोर्ड:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
## योगदान करें
हर तरह का योगदान स्वागत योग्य है, स्टार और बग रिपोर्ट से लेकर बैकएंड सुधार तक। शुरू करने के लिए [CONTRIBUTING.md](CONTRIBUTING.md) देखें।
सभी योगदान स्वागत योग्य है, स्टार और बग रिपोर्ट से लेकर बैकएंड सुधार तक। शुरू करने के लिए [CONTRIBUTING.md](CONTRIBUTING.md) देखें।
हमारे सभी Surfers का धन्यवाद:
हमारे सभी Surfers क धन्यवाद:
<a href="https://github.com/MODSetter/SurfSense/graphs/contributors">
<img src="https://contrib.rocks/image?repo=MODSetter/SurfSense" />
</a>
## स्टार हिस्ट्री
## Star इतिहास
<a href="https://www.star-history.com/#MODSetter/SurfSense&Date">
<picture>

436
README.md
View file

@ -1,4 +1,4 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="SurfSense, the open-source competitive intelligence platform for AI agents" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
@ -20,253 +20,273 @@
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
# SurfSense: Give Your AI Agents Competitive Intelligence
# SurfSense
SurfSense is the **open-source competitive intelligence platform for AI agents**. Your agents monitor competitors, track rankings, and listen to your market with live data from **Reddit, YouTube, Google Maps, Google Search, and the open web**, through one **REST API** or **MCP server**. Scheduled and event-triggered agents turn what they find into briefs and alerts, and a built-in knowledge base keeps every finding searchable with citations.
NotebookLM is one of the best and most useful AI platforms out there, but once you start using it regularly you also feel its limitations leaving something to be desired more.
> [!NOTE]
> **📢 A note for our NotebookLM-alternative users**
>
> For the past couple of months we built SurfSense as the best general research agent for your own knowledge, and that chapter earned us a community we are genuinely proud of. Agentic tools like Claude, OpenCode, Hermes, and OpenClaw have now proven that agents are the future, and general research is becoming something every capable agent does out of the box. What agents still lack is **live market data and the workflows around it**, so that is where we are pointing all of our energy: becoming the definitive open-source competitive intelligence agent platform.
>
> **Nothing you rely on is going away.** Your knowledge base, chat with citations, reports, podcasts, presentations, automations, and collaborative chats all keep working, and self-hosting stays free and open source. Read the full announcement on [our changelog](https://www.surfsense.com/changelog).
1. There are limits on the amount of sources you can add in a notebook.
2. There are limits on the number of notebooks you can have.
3. You cannot have sources that exceed 500,000 words and are more than 200MB.
4. You are vendor locked in to Google services (LLMs, usage models, etc.) with no option to configure them.
5. Limited external data sources and service integrations.
6. NotebookLM Agent is specifically optimised for just studying and researching, but you can do so much more with the source data.
7. Lack of multiplayer support.
## Table of contents
...and more.
- [Why agents need SurfSense](#why-agents-need-surfsense)
- [What can you do with SurfSense?](#what-can-you-do-with-surfsense)
- [Live data connectors](#live-data-connectors)
- [Quick start](#quick-start)
- [Everything else in the box](#everything-else-in-the-box)
- [SurfSense vs Google NotebookLM](#surfsense-vs-google-notebooklm)
- [Roadmap](#roadmap)
- [Contribute](#contribute)
**SurfSense is specifically made to solve these problems.** SurfSense empowers you to:
## Why agents need SurfSense
- **Control Your Data Flow** - Keep your data private and secure.
- **No Data Limits** - Add an unlimited amount of sources and notebooks.
- **No Vendor Lock-in** - Configure any LLM, image, TTS, and STT models to use.
- **25+ External Data Sources** - Add your sources from Google Drive, OneDrive, Dropbox, Notion, and many other external services.
- **Real-Time Multiplayer Support** - Work easily with your team members in a shared notebook.
- **AI File Sorting** - Automatically organize your documents into a smart folder hierarchy using AI-powered categorization by source, date, and topic.
- **AI Automations & Agents** - Run AI agents on a schedule or trigger them the moment a document lands in a folder, then write results back to Notion, Slack, Linear, and Drive. Build no-code automations just by describing them in chat.
- **Desktop App** - Get AI assistance in any application with Quick Assist, General Assist, Screenshot Assist, and local folder sync.
Ask any capable agent "what are competitors charging this week?" or "what is Reddit saying about us since the launch?" and it has nowhere trustworthy to look. Official platform APIs are rate-limited, priced for enterprises, or missing entirely, and scraping plumbing is brittle. SurfSense closes that gap:
...and more to come.
- **Platform-native connectors**, each a typed REST endpoint returning structured JSON. No rate-limit roulette, no HTML parsing.
- **An MCP server** that exposes every connector as a native tool (`surfsense_reddit_scrape`, `surfsense_google_search`, and more) to Claude, Cursor, or any agent framework.
- **An agent harness**, not just raw data: retries, structured output, and credit metering are built in, so agents go from a question to a brief without you building the plumbing.
- **Open source and self-hostable**, so your competitive research stays on your own infrastructure.
## What can you do with SurfSense?
Every use case below is a real task the SurfSense agent runs end-to-end today, in one prompt or on a schedule.
### Multi-connector workflows
Chain several connectors in a single agent run and get one cited brief back.
- **Launch impact, across every platform** — "Our competitor launched v2 yesterday. Measure the reaction across search, Reddit, and YouTube." The agent scrapes SERPs, Reddit threads, and YouTube comments, then merges all three signals into one launch-impact brief.
- **Local competitor teardown** — Google Maps finds the players, the web crawler reads their pricing pages, and Google Search shows who wins the query, in one run.
- **Competitor 360, on a schedule** — an automation chains four connectors every week: site changes, rank movements, Reddit sentiment, and YouTube reaction.
### Competitor monitoring
- **Pricing watch** — extract every plan, price, and limit from competitors' pricing pages into one table, then re-check daily and alert on any change.
- **Product & changelog tracking** — crawl rivals' changelog, product, and careers pages every Monday and get a brief on what they shipped.
- **Rank & ad monitoring** — track the Google rankings, paid ads, and AI Overview citations your market actually sees, and flag movements day over day.
### B2B lead generation
- **Local business leads** — turn a category and a territory ("burger places in San Jose") into a lead list with phones, websites, ratings, and decision-maker contacts pulled from their sites.
- **Team rosters & contacts** — spider any company site and pull the full team with emails, socials, and source provenance, exported to CSV.
- **Portfolio & market mapping** — map an investor's portfolio or a whole category, then enrich every company with pricing and contacts.
### Brand & market listening
- **Reddit brand monitoring** — hear what your market says about you, your competitors, and your category in the threads where buyers speak candidly.
- **YouTube audience sentiment** — pull videos, transcripts, and comments at scale, then cluster what audiences praise and complain about.
- **Switcher & intent mining** — find the people actively asking for an alternative to a competitor, ranked by how ready they are to move.
### Market research
- **Deep research on the live web** — the agent crawls dozens of live sources on a question and synthesizes a cited answer, not a stale index.
- **AI Overview & GEO tracking** — capture when Google's AI Overviews answer your market's queries, and exactly which sources they cite.
- **Cited briefs & alerts** — everything the agents gather lands in your workspace as briefs and alerts with sources you can check.
### Automate any of it, no code
Automations run full agent turns on a schedule or in response to events, then write results back to Notion, Slack, Linear, and Jira. Describe the workflow in plain English and SurfSense builds it. Try prompts like:
- "Watch our top 3 competitors' pricing pages and alert me in Slack when a plan or price changes."
- "Track every mention of our brand on Reddit and YouTube and send me a daily digest."
- "Monitor our Google ranking for our top 10 keywords and flag drops week over week."
- "Pull new Google Maps reviews for our locations and our competitors' every Monday."
- "Run a monthly competitor analysis report and save it to my workspace."
## Live data connectors
| Connector | What your agents get | Learn more |
|---|---|---|
| **Reddit** | Posts, comments, and subreddit streams without the official API's rate limits | [Reddit Scraper API](https://www.surfsense.com/reddit) |
| **YouTube** | Videos, transcripts, and comment threads for brand and product listening | [YouTube Scraper API](https://www.surfsense.com/youtube) |
| **Google Maps** | Places, ratings, and reviews for local competitor and lead research | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | Live SERPs for rank tracking and market monitoring | [Google Search API](https://www.surfsense.com/google-search) |
| **Web Crawl** | Any page on the open web as clean, structured content | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **External MCP Connectors** | Bring any MCP server to your agents, with one-click OAuth for Notion, Slack, Jira, and more | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
Billing is pay as you go: connectors bill per item actually returned, crawls per page successfully fetched, and failed calls are never billed. Self-hosted installs run with billing off. See [pricing](https://www.surfsense.com/pricing).
## Quick start
### Call a connector from code
Every connector is a REST endpoint you can call from any language with your SurfSense API key:
```bash
curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape" \
-H "Authorization: Bearer $SURFSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"search_queries": ["your brand"],
"community": "webscraping",
"sort": "top",
"time_filter": "week"
}'
```
Each [connector page](https://www.surfsense.com/connectors) has copy-paste examples in Python, JavaScript, Go, PHP, Ruby, Java, and C#.
### Give the tools to your agents over MCP
Add the SurfSense MCP server to Claude, Cursor, or your own agent framework:
```json
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
}
```
Your agent can now call every connector as a native tool. See the [SurfSense MCP server](https://www.surfsense.com/mcp-server) page for the full tool list, or run the server locally from [`surfsense_mcp`](./surfsense_mcp).
### Use the cloud
Go to [surfsense.com](https://www.surfsense.com), log in, and ask the agent for live market data in plain English. New accounts start with $5 of free credit and no subscription.
### Self-host for free
Run the entire platform, connectors, agents, automations, and the MCP server on your own infrastructure. Self-hosted installs ship with billing off, so scraping, crawling, and agent runs are limited only by your hardware and the model keys you bring.
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
For Linux/macOS:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
For Windows:
```bash
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
The install script sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) automatically for daily auto-updates. To skip it, add the `--no-watchtower` flag. For Docker Compose, manual installation, and other deployment options, see the [docs](https://www.surfsense.com/docs/).
## Everything else in the box
The research workspace that made SurfSense the leading open-source NotebookLM alternative is still here, and everything your agents gather lands in it.
**Knowledge base**
- Upload PDFs, Office docs, images, and audio, or sync **Google Drive, OneDrive, and Dropbox**. 50+ file formats supported.
- Hybrid semantic and full-text search with cited, Perplexity-style answers.
- AI file sorting auto-organizes documents by source, date, and topic.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="Chat With Your PDFs and Docs" /></p>
**Deliverable studio**
- AI report generator with export to PDF, DOCX, HTML, LaTeX, EPUB, ODT, or plain text.
- Two-host AI podcasts from any document or folder in under 20 seconds.
- Editable slide decks, narrated video overviews, and AI image generation.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="AI Report Generator" /></p>
**Team collaboration**
- Real-time collaborative AI chats with comments and mentions.
- RBAC with Owner, Admin, Editor, and Viewer roles.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Collaborative AI Chat" /></p>
**Desktop app**
Native AI assistance in every application on your computer. Download from the [latest release](https://github.com/MODSetter/SurfSense/releases/latest).
- **General Assist**: launch SurfSense from any app with a global shortcut.
- **Quick Assist**: select text anywhere, then ask AI to explain, rewrite, or act on it.
- **Screenshot Assist**: capture any region of your screen and ask AI about it.
- **Watch Local Folder**: auto-sync a local folder to your knowledge base. Point it at your Obsidian vault to keep your notes searchable.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
**No vendor lock-in**
- 100+ LLMs via the OpenAI spec and LiteLLM, including GPT-5.5, Claude Sonnet 5, and Gemini 3.1 Pro.
- 6,000+ embedding models and all major rerankers.
- Full local and private LLM support (vLLM, Ollama), so your data stays yours.
## Video Agent Sample
https://github.com/user-attachments/assets/012a7ffa-6f76-4f06-9dda-7632b470057a
## Podcast Agent Sample
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## How to collaborate in real time (Beta)
1. Go to the Manage Members page and create an invite.
## How to Use SurfSense
### Cloud
1. Go to [surfsense.com](https://www.surfsense.com) and login.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/LoginFlowGif.gif" alt="Login" /></p>
2. Connect your connectors and sync. Enable periodic syncing to keep connectors synced.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="Connectors" /></p>
3. Till connectors data index, upload Documents.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="Upload Documents" /></p>
4. Once everything is indexed, Ask Away (Use Cases):
**Desktop App** (native extras on top of everything below, not a separate feature set)
- General Assist: launch SurfSense instantly from any application with a global shortcut.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/general_assist.gif" alt="General Assist" /></p>
- Quick Assist: select text anywhere, then ask AI to explain, rewrite, or act on it.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
- Screenshot Assist: capture any region of your screen and ask AI about what's in it.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/screenshot_assist.gif" alt="Screenshot Assist" /></p>
- Watch Local Folder: auto-sync a local folder to your knowledge base. Great for Obsidian vaults.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/folder_watch.gif" alt="Watch Local Folder" /></p>
**Deliverable Studio**
- AI Report Generator: generate cited research reports and export to PDF, DOCX, HTML, LaTeX, EPUB, ODT, or plain text.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="AI Report Generator" /></p>
- AI Podcast Generator: turn any document or folder into a two-host AI podcast in under 20 seconds.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/PodcastGenGif.gif" alt="AI Podcast Generator" /></p>
- AI Presentation & Video Maker: create editable slide decks and narrated video overviews from your sources.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/video_gen_gif.gif" alt="AI Presentation and Video Maker" /></p>
- AI Image Generator: generate high-quality images straight from your chats and documents.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ImageGenGif.gif" alt="AI Image Generator" /></p>
- AI Resume Builder: tailor your existing resume to any job description and beat the ATS.
Try prompts like these:
- "Tailor my resume to this job description so it gets past ATS and lands an interview."
- "Optimize my resume for ATS by matching the keywords in this job posting."
- "Rewrite my resume bullet points to highlight the skills this role is asking for."
- "Compare my resume against this job description and list the gaps to fix."
- "Write a matching cover letter from my resume and this job description."
**Search & Chat**
- Chat With Your PDFs & Docs: ask questions across all your files and get answers with inline citations.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="Chat With Your PDFs and Docs" /></p>
- AI Search With Citations: hybrid semantic and keyword search across your entire knowledge base.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BSNCGif.gif" alt="AI Search With Citations" /></p>
- Collaborative AI Chat: work on AI conversations with your team in real time.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Collaborative AI Chat" /></p>
- Comments & Mentions: comment and tag teammates on any AI message.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comments and Mentions" /></p>
**Connectors & Integrations**
- Connect & Sync Your Tools: sync Notion, Slack, Google Drive, Gmail, GitHub, Linear and 25+ sources into one searchable corpus.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="Connect and Sync Your Tools" /></p>
- Chat With Uploaded Files: drop in PDFs, Office docs, images and audio. Instantly searchable.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="Chat With Uploaded Files" /></p>
- Connector Write-Back: let the agent post results back to Notion, Slack, Linear and Drive.
Try prompts like these:
- "Post this research summary to my Notion workspace."
- "Send these meeting action items to our team Slack channel."
- "Create a Jira ticket from this bug report."
- "Open a Linear issue from this feature request."
- "Save this generated report to Google Drive as a doc."
- Obsidian & Knowledge Base Sync: keep your Obsidian vault and personal knowledge base in sync.
**Automations**
- Scheduled AI Workflows: run an agent on a schedule: daily briefs, weekly digests, recurring reports.
Try prompts like these:
- "Email me a daily brief of new documents in my knowledge base every morning."
- "Generate a weekly status report from my Slack and Gmail every Friday."
- "Run a monthly competitor analysis report and save it to my workspace."
- "Summarize my GitHub and Linear activity into a daily standup update."
- "Create a recurring weekly research report on the topics I track."
- Event-Triggered Automations: fire an agent the moment a document lands in a folder, then post the result to your tools.
Try prompts like these:
- "When a PDF lands in my Research folder, generate a cited AI summary."
- "When new meeting notes are added, turn them into meeting minutes with action items."
- "When an invoice is uploaded, extract the vendor, total, and due date into a table."
- "When a contract enters my Legal folder, flag key terms and renewal dates."
- "When a resume is added to Candidates, screen it against the job description."
- Chat-Built Automations: describe an automation in plain English and SurfSense builds it for you.
Try prompts like these:
- "Build an AI agent that emails me a summary of new Notion pages each morning."
- "Create a no-code automation that posts a weekly research digest to Slack."
- "Set up an AI note taker that turns new meeting notes into minutes."
- "Make a workflow that extracts action items from meeting notes and assigns owners."
- "Automate a daily email brief from my Gmail and Google Drive."
### Self Hosted
Run SurfSense on your own infrastructure for full data control and privacy.
**Prerequisites:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) must be installed and running.
#### For Linux/MacOS users:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
#### For Windows users:
```bash
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
The install script sets up [Watchtower](https://github.com/nicholas-fedor/watchtower) automatically for daily auto-updates. To skip it, add the `--no-watchtower` flag.
For Docker Compose, manual installation, and other deployment options, see the [docs](https://www.surfsense.com/docs/).
### Desktop App
SurfSense also ships a desktop app that brings AI assistance to every application on your computer. Download it from the [latest release](https://github.com/MODSetter/SurfSense/releases/latest).
The desktop app includes these powerful features:
- **General Assist** — Launch SurfSense instantly from any application with a global shortcut.
- **Quick Assist** — Select text anywhere, then ask AI to explain, rewrite, or act on it.
- **Screenshot Assist** — Select a region on your screen and attach it to chat so answers stay grounded in your knowledge base.
- **Watch Local Folder** — Watch a local folder and automatically sync file changes to your knowledge base. **Pro tip:** Point it at your Obsidian vault to keep your notes searchable in SurfSense.
All features operate against your chosen search space, so your answers are always grounded in your own data.
### How to Realtime Collaborate (Beta)
1. Go to Manage Members page and create an invite.
<p align="center"><img src="https://github.com/user-attachments/assets/40ed7683-5aa6-48a0-a3df-00575528c392" alt="Invite Members" /></p>
2. A teammate joins and that workspace becomes shared.
2. Teammate joins and that SearchSpace becomes shared.
<p align="center"><img src="https://github.com/user-attachments/assets/ea4e1057-4d2b-4fd2-9ca0-cd19286a285e" alt="Invite Join Flow" /></p>
3. Make a chat shared and work in it together in real time, with comments to tag teammates.
3. Make chat shared.
<p align="center"><img src="https://github.com/user-attachments/assets/17b93904-0888-4c3a-ac12-51a24a8ea26a" alt="Make Chat Shared" /></p>
4. Your team can now chat in realtime.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Realtime Chat" /></p>
5. Add comment to tag teammates.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Realtime Comments" /></p>
## SurfSense vs Google NotebookLM
Still comparing us as a NotebookLM alternative? Here is the honest breakdown.
| Feature | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
| **Live market data for agents** | No | Reddit, YouTube, Google Maps, Google Search, and web crawl connectors via REST API and MCP |
| **MCP server** | No | Every connector exposed as a native agent tool, plus bring-your-own MCP servers with one-click OAuth apps |
| **Sources per Notebook** | 50 (Free) to 600 (Ultra, $249.99/mo) | Unlimited |
| **Number of Notebooks** | 100 (Free) to 500 (paid tiers) | Unlimited |
| **Source Size Limit** | 500,000 words / 200MB per source | No limit |
| **Pricing** | Free tier; Pro $19.99/mo, Ultra $249.99/mo | Free and open source to self-host; cloud is pay as you go with $5 free credit |
| **Pricing** | Free tier available; Pro $19.99/mo, Ultra $249.99/mo | Free and open source, self-host on your own infra |
| **LLM Support** | Google Gemini only | 100+ LLMs via OpenAI spec & LiteLLM |
| **Embedding Models** | Google only | 6,000+ embedding models, all major rerankers |
| **Local / Private LLMs** | Not available | Full support (vLLM, Ollama), your data stays yours |
| **Self Hostable** | No | Yes, Docker one-liner or full Docker Compose |
| **Local / Private LLMs** | Not available | Full support (vLLM, Ollama) - your data stays yours |
| **Self Hostable** | No | Yes - Docker one-liner or full Docker Compose |
| **Open Source** | No | Yes |
| **Knowledge Base Sources** | Google Drive, YouTube, websites | File uploads, Google Drive, OneDrive, Dropbox, local folder sync, and crawled pages |
| **File Format Support** | PDFs, Docs, Slides, Sheets, CSV, Word, EPUB, images, web URLs, YouTube | 50+ formats: documents, images, videos via LlamaCloud, Unstructured, or Docling (local) |
| **Search** | Semantic search | Hybrid semantic + full-text with hierarchical indices & reciprocal rank fusion |
| **Cited Answers** | Yes | Yes, Perplexity-style cited responses |
| **Agentic Architecture** | No | Yes, powered by [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) with planning, subagents, and file system access |
| **AI Automations & Agents** | No | Scheduled workflows, event triggers, and chat-built no-code automations with write-back to Notion, Slack, Linear & Jira |
| **External Connectors** | Google Drive, YouTube, websites | 27+ connectors - Search Engines, Google Drive, OneDrive, Dropbox, Slack, Teams, Jira, Notion, GitHub, Discord & [more](#external-sources) |
| **File Format Support** | PDFs, Docs, Slides, Sheets, CSV, Word, EPUB, images, web URLs, YouTube | 50+ formats - documents, images, videos via LlamaCloud, Unstructured, or Docling (local) |
| **Search** | Semantic search | Hybrid Search - Semantic + Full Text with Hierarchical Indices & Reciprocal Rank Fusion |
| **Cited Answers** | Yes | Yes - Perplexity-style cited responses |
| **Agentic Architecture** | No | Yes - powered by [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) with planning, subagents, and file system access |
| **Real-Time Multiplayer** | Shared notebooks with Viewer/Editor roles (no real-time chat) | RBAC with Owner / Admin / Editor / Viewer roles, real-time chat & comment threads |
| **Video Generation** | Cinematic Video Overviews via Veo 3 (Ultra only) | Available (NotebookLM is better here, actively improving) |
| **Presentation Generation** | Better looking slides but not editable | Editable, slide-based presentations |
| **Presentation Generation** | Better looking slides but not editable | Create editable, slide-based presentations |
| **Podcast Generation** | Audio Overviews with customizable hosts and languages | Available with multiple TTS providers (NotebookLM is better here, actively improving) |
| **AI File Sorting** | No | LLM-powered auto-categorization into source, date, category, and subcategory folders |
| **AI Automations & Agents** | No | Scheduled AI workflows, event triggers on new documents, and chat-built no-code automations with connector write-back to Notion, Slack, Linear & Jira |
| **Desktop App** | No | Native app with General Assist, Quick Assist, Screenshot Assist, and local folder sync |
| **Browser Extension** | No | Cross-browser extension to save any webpage, including auth-protected pages |
<details>
<summary><b>Full list of External Sources</b></summary>
<a id="external-sources"></a>
Search Engines (SearXNG, Tavily, LinkUp, Baidu Search) · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube Videos · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, and more to come.
</details>
## FEATURE REQUESTS AND FUTURE
## Feature requests and future
**SurfSense is actively being developed.** While it's not yet production-ready, you can help us speed up the process.
@ -274,12 +294,14 @@ Join the [SurfSense Discord](https://discord.gg/ejRNvftDp9) and help shape the f
## Roadmap
Stay up to date with our development progress and upcoming features. Check out our public roadmap and contribute your ideas or feedback:
Stay up to date with our development progress and upcoming features!
Check out our public roadmap and contribute your ideas or feedback:
**Roadmap Discussion:** [SurfSense 2026 Roadmap](https://github.com/MODSetter/SurfSense/discussions/565)
**Kanban Board:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
## Contribute
All contributions welcome, from stars and bug reports to backend improvements. See [CONTRIBUTING.md](CONTRIBUTING.md) to get started.

View file

@ -1,4 +1,4 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="SurfSense, a plataforma open source de inteligência competitiva para agentes de IA" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
@ -20,270 +20,289 @@
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
# SurfSense: Dê Inteligência Competitiva aos Seus Agentes de IA
# SurfSense
O SurfSense é a **plataforma open source de inteligência competitiva para agentes de IA**. Seus agentes monitoram concorrentes, acompanham rankings e escutam o seu mercado com dados ao vivo do **Reddit, YouTube, Google Maps, Google Search e da web aberta**, por meio de uma única **API REST** ou de um **servidor MCP**. Agentes agendados ou acionados por eventos transformam o que encontram em relatórios e alertas, e uma base de conhecimento integrada mantém cada descoberta pesquisável, com citações.
O NotebookLM é uma das melhores e mais úteis plataformas de IA disponíveis, mas quando você começa a usá-lo regularmente também sente suas limitações deixando algo a desejar.
> [!NOTE]
> **📢 Um recado para nossos usuários que buscavam uma alternativa ao NotebookLM**
>
> Nos últimos meses, construímos o SurfSense como o melhor agente de pesquisa geral para o seu próprio conhecimento, e esse capítulo nos rendeu uma comunidade da qual temos muito orgulho. Ferramentas agênticas como Claude, OpenCode, Hermes e OpenClaw já provaram que os agentes são o futuro, e a pesquisa geral está se tornando algo que todo agente capaz faz nativamente. O que ainda falta aos agentes são **dados de mercado ao vivo e os fluxos de trabalho em torno deles**, então é para lá que estamos direcionando toda a nossa energia: nos tornar a plataforma open source definitiva de agentes de inteligência competitiva.
>
> **Nada do que você usa vai deixar de existir.** Sua base de conhecimento, o chat com citações, os relatórios, os podcasts, as apresentações, as automações e os chats colaborativos continuam funcionando, e a auto-hospedagem segue gratuita e open source. Leia o anúncio completo no [nosso changelog](https://www.surfsense.com/changelog).
1. Há limites na quantidade de fontes que você pode adicionar em um notebook.
2. Há limites no número de notebooks que você pode ter.
3. Você não pode ter fontes que excedam 500.000 palavras e mais de 200MB.
4. Você fica preso aos serviços do Google (LLMs, modelos de uso, etc.) sem opção de configurá-los.
5. Fontes de dados externas e integrações de serviços limitadas.
6. O agente do NotebookLM é especificamente otimizado apenas para estudar e pesquisar, mas você pode fazer muito mais com os dados de origem.
7. Falta de suporte multiplayer.
## Sumário
...e mais.
- [Por que os agentes precisam do SurfSense](#por-que-os-agentes-precisam-do-surfsense)
- [O que você pode fazer com o SurfSense?](#o-que-você-pode-fazer-com-o-surfsense)
- [Conectores de dados ao vivo](#conectores-de-dados-ao-vivo)
- [Início rápido](#início-rápido)
- [Tudo o mais que vem na caixa](#tudo-o-mais-que-vem-na-caixa)
- [SurfSense vs Google NotebookLM](#surfsense-vs-google-notebooklm)
- [Roadmap](#roadmap)
- [Contribua](#contribua)
**O SurfSense foi feito especificamente para resolver esses problemas.** O SurfSense permite que você:
## Por que os agentes precisam do SurfSense
- **Controle Seu Fluxo de Dados** - Mantenha seus dados privados e seguros.
- **Sem Limites de Dados** - Adicione uma quantidade ilimitada de fontes e notebooks.
- **Sem Dependência de Fornecedor** - Configure qualquer modelo LLM, de imagem, TTS e STT.
- **25+ Fontes de Dados Externas** - Adicione suas fontes do Google Drive, OneDrive, Dropbox, Notion e muitos outros serviços externos.
- **Suporte Multiplayer em Tempo Real** - Trabalhe facilmente com os membros da sua equipe em um notebook compartilhado.
- **Automações e Agentes de IA** - Execute agentes de IA em uma programação ou dispare-os no momento em que um documento chega a uma pasta, e escreva os resultados de volta no Notion, Slack, Linear e Drive. Crie automações sem código apenas descrevendo-as no chat.
- **Aplicativo Desktop** - Obtenha assistência de IA em qualquer aplicativo com Quick Assist, General Assist, Screenshot Assist e sincronização de pastas locais.
Pergunte a qualquer agente capaz "quanto os concorrentes estão cobrando esta semana?" ou "o que o Reddit está dizendo sobre nós desde o lançamento?" e ele não terá nenhum lugar confiável para procurar. As APIs oficiais das plataformas têm limites de requisições, preços voltados para empresas ou simplesmente não existem, e a infraestrutura de scraping é frágil. O SurfSense fecha essa lacuna:
- **Conectores nativos de cada plataforma**, cada um sendo um endpoint REST tipado que retorna JSON estruturado. Sem roleta de limites de requisição, sem parsing de HTML.
- **Um servidor MCP** que expõe cada conector como uma ferramenta nativa (`surfsense_reddit_scrape`, `surfsense_google_search` e outras) para o Claude, o Cursor ou qualquer framework de agentes.
- **Um harness de agentes**, não apenas dados brutos: novas tentativas, saída estruturada e medição de créditos já vêm prontos, então os agentes vão de uma pergunta a um relatório sem que você precise construir a infraestrutura.
- **Open source e auto-hospedável**, para que sua pesquisa competitiva permaneça na sua própria infraestrutura.
## O que você pode fazer com o SurfSense?
Cada caso de uso abaixo é uma tarefa real que o agente do SurfSense executa de ponta a ponta hoje, em um único prompt ou de forma agendada.
### Fluxos de trabalho multi-conector
Encadeie vários conectores em uma única execução do agente e receba um único relatório com citações.
- **Impacto de um lançamento, em todas as plataformas** — "Nosso concorrente lançou a v2 ontem. Meça a reação nas buscas, no Reddit e no YouTube." O agente raspa SERPs, threads do Reddit e comentários do YouTube, e depois funde os três sinais em um único relatório de impacto do lançamento.
- **Análise de concorrentes locais** — o Google Maps encontra os players, o rastreador web lê suas páginas de preços e o Google Search mostra quem vence a busca, tudo em uma única execução.
- **Concorrente 360, de forma agendada** — uma automação encadeia quatro conectores toda semana: mudanças no site, movimentos de ranking, sentimento no Reddit e reação no YouTube.
### Monitoramento de concorrentes
- **Vigilância de preços** — extraia cada plano, preço e limite das páginas de preços dos concorrentes em uma única tabela, depois verifique novamente todo dia e receba alertas sobre qualquer mudança.
- **Acompanhamento de produto e changelog** — rastreie as páginas de changelog, produto e vagas dos rivais toda segunda-feira e receba um relatório do que eles lançaram.
- **Monitoramento de rankings e anúncios** — acompanhe os rankings do Google, os anúncios pagos e as citações em AI Overviews que o seu mercado realmente vê, e sinalize os movimentos dia a dia.
### Geração de leads B2B
- **Leads de negócios locais** — transforme uma categoria e um território ("hamburguerias em San Jose") em uma lista de leads com telefones, sites, avaliações e contatos de decisores extraídos dos sites deles.
- **Equipes e contatos** — rastreie o site de qualquer empresa e extraia a equipe completa com e-mails, redes sociais e a origem de cada dado, exportado para CSV.
- **Mapeamento de portfólios e mercados** — mapeie o portfólio de um investidor ou uma categoria inteira, e depois enriqueça cada empresa com preços e contatos.
### Escuta de marca e mercado
- **Monitoramento de marca no Reddit** — ouça o que o seu mercado diz sobre você, seus concorrentes e sua categoria nas threads onde os compradores falam com franqueza.
- **Sentimento da audiência no YouTube** — colete vídeos, transcrições e comentários em escala, e depois agrupe o que as audiências elogiam e criticam.
- **Mineração de intenção e de quem quer trocar** — encontre as pessoas que estão ativamente buscando uma alternativa a um concorrente, ordenadas por quão prontas estão para migrar.
### Pesquisa de mercado
- **Pesquisa profunda na web ao vivo** — o agente rastreia dezenas de fontes ao vivo sobre uma pergunta e sintetiza uma resposta com citações, não um índice desatualizado.
- **Acompanhamento de AI Overviews e GEO** — capture quando os AI Overviews do Google respondem às buscas do seu mercado, e exatamente quais fontes eles citam.
- **Relatórios e alertas com citações** — tudo o que os agentes coletam chega ao seu workspace como relatórios e alertas com fontes que você pode verificar.
### Automatize qualquer uma dessas tarefas, sem código
As automações executam turnos completos de agente de forma agendada ou em resposta a eventos, e depois gravam os resultados no Notion, Slack, Linear e Jira. Descreva o fluxo de trabalho em linguagem natural e o SurfSense o constrói. Experimente prompts como:
- "Monitore as páginas de preços dos nossos 3 principais concorrentes e me avise no Slack quando um plano ou preço mudar."
- "Acompanhe cada menção à nossa marca no Reddit e no YouTube e me envie um resumo diário."
- "Monitore nosso ranking no Google para nossas 10 principais palavras-chave e sinalize quedas semana a semana."
- "Colete os novos reviews do Google Maps das nossas unidades e das dos concorrentes toda segunda-feira."
- "Execute um relatório mensal de análise de concorrentes e salve no meu workspace."
## Conectores de dados ao vivo
| Conector | O que seus agentes recebem | Saiba mais |
|---|---|---|
| **Reddit** | Posts, comentários e fluxos de subreddits sem os limites de requisição da API oficial | [Reddit Scraper API](https://www.surfsense.com/reddit) |
| **YouTube** | Vídeos, transcrições e threads de comentários para monitoramento de marca e produto | [YouTube Scraper API](https://www.surfsense.com/youtube) |
| **Google Maps** | Estabelecimentos, avaliações e reviews para pesquisa local de concorrentes e leads | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | SERPs ao vivo para acompanhamento de rankings e monitoramento de mercado | [Google Search API](https://www.surfsense.com/google-search) |
| **Web Crawl** (rastreamento web) | Qualquer página da web aberta como conteúdo limpo e estruturado | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **Conectores MCP externos** | Traga qualquer servidor MCP para seus agentes, com OAuth em um clique para Notion, Slack, Jira e outros | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
A cobrança é por uso: os conectores cobram por item efetivamente retornado, os rastreamentos por página obtida com sucesso, e chamadas com falha nunca são cobradas. Instalações auto-hospedadas rodam com a cobrança desativada. Veja os [preços](https://www.surfsense.com/pricing).
## Início rápido
### Chame um conector a partir do código
Cada conector é um endpoint REST que você pode chamar de qualquer linguagem com a sua chave de API do SurfSense:
```bash
curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape" \
-H "Authorization: Bearer $SURFSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"search_queries": ["your brand"],
"community": "webscraping",
"sort": "top",
"time_filter": "week"
}'
```
Cada [página de conector](https://www.surfsense.com/connectors) tem exemplos prontos para copiar e colar em Python, JavaScript, Go, PHP, Ruby, Java e C#.
### Entregue as ferramentas aos seus agentes via MCP
Adicione o servidor MCP do SurfSense ao Claude, ao Cursor ou ao seu próprio framework de agentes:
```json
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
}
```
Seu agente agora pode chamar cada conector como uma ferramenta nativa. Veja a página do [servidor MCP do SurfSense](https://www.surfsense.com/mcp-server) para a lista completa de ferramentas, ou execute o servidor localmente a partir de [`surfsense_mcp`](./surfsense_mcp).
### Use a nuvem
Acesse [surfsense.com](https://www.surfsense.com), faça login e peça ao agente dados de mercado ao vivo em linguagem natural. Contas novas começam com US$ 5 de crédito gratuito e sem assinatura.
...e mais por vir.
### Auto-hospede gratuitamente
Rode a plataforma inteira, conectores, agentes, automações e o servidor MCP na sua própria infraestrutura. Instalações auto-hospedadas vêm com a cobrança desativada, então scraping, rastreamento e execuções de agentes são limitados apenas pelo seu hardware e pelas chaves de modelo que você trouxer.
## Exemplo de Agente de Vídeo
**Pré-requisitos:** o [Docker Desktop](https://www.docker.com/products/docker-desktop/) precisa estar instalado e em execução.
https://github.com/user-attachments/assets/012a7ffa-6f76-4f06-9dda-7632b470057a
Para Linux/macOS:
## Exemplo de Agente de Podcast
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## Como Usar o SurfSense
### Cloud
1. Acesse [surfsense.com](https://www.surfsense.com) e faça login.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/LoginFlowGif.gif" alt="Login" /></p>
2. Conecte seus conectores e sincronize. Ative a sincronização periódica para manter os conectores atualizados.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="Conectores" /></p>
3. Enquanto os dados dos conectores são indexados, faça upload de documentos.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="Upload de Documentos" /></p>
4. Quando tudo estiver indexado, pergunte o que quiser (Casos de uso):
**Aplicativo Desktop** (extras nativos, além de tudo o que está abaixo, não um conjunto separado)
- General Assist: abra o SurfSense instantaneamente de qualquer aplicativo com um atalho global.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/general_assist.gif" alt="General Assist" /></p>
- Quick Assist: selecione um texto em qualquer lugar e peça à IA para explicar, reescrever ou agir sobre ele.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
- Screenshot Assist: capture qualquer região da tela e pergunte à IA sobre o que está nela.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/screenshot_assist.gif" alt="Screenshot Assist" /></p>
- Watch Local Folder: sincronize automaticamente uma pasta local com sua base de conhecimento. Ótimo para cofres do Obsidian.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/folder_watch.gif" alt="Watch Local Folder" /></p>
**Estúdio de Entregáveis**
- AI Report Generator: gere relatórios de pesquisa com citações e exporte para PDF, DOCX, HTML, LaTeX, EPUB, ODT ou texto simples.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="AI Report Generator" /></p>
- AI Podcast Generator: transforme qualquer documento ou pasta em um podcast de IA com dois apresentadores em menos de 20 segundos.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/PodcastGenGif.gif" alt="AI Podcast Generator" /></p>
- AI Presentation & Video Maker: crie apresentações editáveis e vídeos narrados a partir das suas fontes.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/video_gen_gif.gif" alt="AI Presentation and Video Maker" /></p>
- AI Image Generator: gere imagens de alta qualidade diretamente das suas conversas e documentos.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ImageGenGif.gif" alt="AI Image Generator" /></p>
- AI Resume Builder: adapte seu currículo atual a qualquer descrição de vaga e supere o ATS.
Experimente prompts como estes:
- "Adapte meu currículo a esta descrição de vaga para passar pelo ATS e conseguir uma entrevista."
- "Otimize meu currículo para o ATS combinando as palavras-chave desta vaga."
- "Reescreva os tópicos do meu currículo para destacar as habilidades que esta vaga exige."
- "Compare meu currículo com esta descrição de vaga e liste as lacunas a corrigir."
- "Escreva uma carta de apresentação combinando com meu currículo e esta descrição de vaga."
**Busca e Chat**
- Chat With Your PDFs & Docs: faça perguntas sobre todos os seus arquivos e receba respostas com citações inline.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="Chat With Your PDFs and Docs" /></p>
- AI Search With Citations: busca híbrida semântica e por palavra-chave em toda a sua base de conhecimento.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BSNCGif.gif" alt="AI Search With Citations" /></p>
- Collaborative AI Chat: trabalhe em conversas de IA com sua equipe em tempo real.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Collaborative AI Chat" /></p>
- Comments & Mentions: comente e marque colegas em qualquer mensagem de IA.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comments and Mentions" /></p>
**Conectores e Integrações**
- Connect & Sync Your Tools: sincronize Notion, Slack, Google Drive, Gmail, GitHub, Linear e mais de 25 fontes em um único acervo pesquisável.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="Connect and Sync Your Tools" /></p>
- Chat With Uploaded Files: envie PDFs, documentos do Office, imagens e áudio. Pesquisáveis instantaneamente.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="Chat With Uploaded Files" /></p>
- Connector Write-Back: deixe o agente publicar os resultados de volta no Notion, Slack, Linear e Drive.
Experimente prompts como estes:
- "Publique este resumo de pesquisa no meu espaço do Notion."
- "Envie estes itens de ação da reunião para o nosso canal do Slack."
- "Crie um ticket no Jira a partir deste relatório de bug."
- "Abra uma issue no Linear a partir desta solicitação de funcionalidade."
- "Salve este relatório gerado no Google Drive como um documento."
- Obsidian & Knowledge Base Sync: mantenha seu cofre do Obsidian e sua base de conhecimento pessoal sincronizados.
**Automações**
- Scheduled AI Workflows: execute um agente em uma programação: resumos diários, boletins semanais, relatórios recorrentes.
Experimente prompts como estes:
- "Envie-me todas as manhãs um resumo diário dos novos documentos na minha base de conhecimento."
- "Gere um relatório de status semanal a partir do meu Slack e Gmail toda sexta-feira."
- "Execute um relatório mensal de análise da concorrência e salve-o no meu espaço de trabalho."
- "Resuma minha atividade no GitHub e Linear em uma atualização diária de standup."
- "Crie um relatório de pesquisa semanal recorrente sobre os temas que acompanho."
- Event-Triggered Automations: dispare um agente no momento em que um documento chega a uma pasta e publique o resultado nas suas ferramentas.
Experimente prompts como estes:
- "Quando um PDF chegar à minha pasta de Pesquisa, gere um resumo com IA e citações."
- "Quando novas notas de reunião forem adicionadas, transforme-as em atas com itens de ação."
- "Quando uma fatura for enviada, extraia o fornecedor, o total e a data de vencimento em uma tabela."
- "Quando um contrato entrar na minha pasta Jurídica, sinalize os termos-chave e as datas de renovação."
- "Quando um currículo for adicionado a Candidatos, avalie-o em relação à descrição da vaga."
- Chat-Built Automations: descreva uma automação em linguagem simples e o SurfSense a cria para você.
Experimente prompts como estes:
- "Crie um agente de IA que me envie todas as manhãs um resumo das novas páginas do Notion."
- "Crie uma automação sem código que publique um resumo de pesquisa semanal no Slack."
- "Configure um anotador com IA que transforme as novas notas de reunião em atas."
- "Crie um fluxo que extraia os itens de ação das notas de reunião e atribua responsáveis."
- "Automatize um resumo diário por e-mail a partir do meu Gmail e Google Drive."
### Auto-Hospedado
Execute o SurfSense na sua própria infraestrutura para controle total de dados e privacidade.
**Pré-requisitos:** [Docker Desktop](https://www.docker.com/products/docker-desktop/) deve estar instalado e em execução.
#### Para usuários de Linux/MacOS:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
Para Windows:
#### Para usuários do Windows:
```bash
```powershell
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
O script de instalação configura o [Watchtower](https://github.com/nicholas-fedor/watchtower) automaticamente para atualizações automáticas diárias. Para pular essa etapa, adicione a flag `--no-watchtower`. Para Docker Compose, instalação manual e outras opções de implantação, consulte a [documentação](https://www.surfsense.com/docs/).
O script de instalação configura o [Watchtower](https://github.com/nicholas-fedor/watchtower) automaticamente para atualizações diárias. Para pular, adicione a flag `--no-watchtower`.
## Tudo o mais que vem na caixa
Para Docker Compose, instalação manual e outras opções de implantação, consulte a [documentação](https://www.surfsense.com/docs/).
O workspace de pesquisa que fez do SurfSense a principal alternativa open source ao NotebookLM continua aqui, e tudo o que seus agentes coletam chega até ele.
### Aplicativo Desktop
**Base de conhecimento**
O SurfSense também oferece um aplicativo desktop que traz assistência de IA para cada aplicativo no seu computador. Baixe-o na [última versão](https://github.com/MODSetter/SurfSense/releases/latest).
- Envie PDFs, documentos do Office, imagens e áudio, ou sincronize **Google Drive, OneDrive e Dropbox**. Mais de 50 formatos de arquivo suportados.
- Busca híbrida semântica e de texto completo, com respostas citadas no estilo Perplexity.
- Organização de arquivos por IA que classifica automaticamente os documentos por origem, data e tópico.
O aplicativo desktop inclui estes recursos poderosos:
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="Converse com seus PDFs e documentos" /></p>
- **General Assist** — Abra o SurfSense instantaneamente de qualquer aplicativo com um atalho global.
- **Quick Assist** — Selecione texto em qualquer lugar, depois peça à IA para explicar, reescrever ou agir sobre ele.
- **Screenshot Assist** — Selecione uma região da tela e anexe ao chat para respostas fundamentadas na sua base de conhecimento.
- **Watch Local Folder** — Monitore uma pasta local e sincronize automaticamente as alterações de arquivos com sua base de conhecimento. **Pro tip:** Aponte para seu cofre do Obsidian para manter suas notas pesquisáveis no SurfSense.
**Estúdio de entregáveis**
Todos os recursos operam no espaço de busca escolhido, para que suas respostas sejam sempre baseadas nos seus próprios dados.
- Gerador de relatórios com IA, com exportação para PDF, DOCX, HTML, LaTeX, EPUB, ODT ou texto simples.
- Podcasts de IA com dois apresentadores a partir de qualquer documento ou pasta em menos de 20 segundos.
- Apresentações de slides editáveis, resumos em vídeo narrados e geração de imagens por IA.
### Como Colaborar em Tempo Real (Beta)
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="Gerador de Relatórios com IA" /></p>
1. Acesse a página de Gerenciar Membros e crie um convite.
**Colaboração em equipe**
<p align="center"><img src="https://github.com/user-attachments/assets/40ed7683-5aa6-48a0-a3df-00575528c392" alt="Convidar Membros" /></p>
- Chats de IA colaborativos em tempo real, com comentários e menções.
- RBAC com os papéis de Owner, Admin, Editor e Viewer.
2. O colega aceita e aquele SearchSpace se torna compartilhado.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Chat de IA colaborativo" /></p>
<p align="center"><img src="https://github.com/user-attachments/assets/ea4e1057-4d2b-4fd2-9ca0-cd19286a285e" alt="Fluxo de Entrada por Convite" /></p>
**Aplicativo desktop**
3. Torne o chat compartilhado.
Assistência de IA nativa em todos os aplicativos do seu computador. Baixe na [versão mais recente](https://github.com/MODSetter/SurfSense/releases/latest).
<p align="center"><img src="https://github.com/user-attachments/assets/17b93904-0888-4c3a-ac12-51a24a8ea26a" alt="Tornar Chat Compartilhado" /></p>
- **General Assist**: abra o SurfSense a partir de qualquer aplicativo com um atalho global.
- **Quick Assist**: selecione texto em qualquer lugar e peça à IA para explicar, reescrever ou agir sobre ele.
- **Screenshot Assist**: capture qualquer região da tela e faça perguntas à IA sobre ela.
- **Watch Local Folder** (monitorar pasta local): sincronize automaticamente uma pasta local com a sua base de conhecimento. Aponte para o seu cofre do Obsidian para manter suas notas pesquisáveis.
4. Sua equipe agora pode conversar em tempo real.
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Chat em Tempo Real" /></p>
**Sem dependência de fornecedor**
5. Adicione comentários para marcar colegas de equipe.
- Mais de 100 LLMs via especificação da OpenAI e LiteLLM, incluindo GPT-5.5, Claude Sonnet 5 e Gemini 3.1 Pro.
- Mais de 6.000 modelos de embedding e todos os principais rerankers.
- Suporte completo a LLMs locais e privados (vLLM, Ollama), para que seus dados continuem sendo seus.
## Exemplo do Agente de Vídeo
https://github.com/user-attachments/assets/012a7ffa-6f76-4f06-9dda-7632b470057a
## Exemplo do Agente de Podcast
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## Como colaborar em tempo real (Beta)
1. Vá até a página Manage Members e crie um convite.
<p align="center"><img src="https://github.com/user-attachments/assets/40ed7683-5aa6-48a0-a3df-00575528c392" alt="Convidar membros" /></p>
2. Um colega entra e aquele workspace passa a ser compartilhado.
<p align="center"><img src="https://github.com/user-attachments/assets/ea4e1057-4d2b-4fd2-9ca0-cd19286a285e" alt="Fluxo de entrada por convite" /></p>
3. Torne um chat compartilhado e trabalhem nele juntos em tempo real, com comentários para marcar colegas.
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comentários em tempo real" /></p>
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comentários em Tempo Real" /></p>
## SurfSense vs Google NotebookLM
Ainda nos comparando como alternativa ao NotebookLM? Aqui está o comparativo honesto.
| Recurso | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
| **Dados de mercado ao vivo para agentes** | Não | Conectores de Reddit, YouTube, Google Maps, Google Search e rastreamento web via API REST e MCP |
| **Servidor MCP** | Não | Cada conector exposto como ferramenta nativa de agente, além de servidores MCP próprios com apps OAuth em um clique |
| **Fontes por Notebook** | 50 (gratuito) a 600 (Ultra, US$ 249,99/mês) | Ilimitadas |
| **Número de Notebooks** | 100 (gratuito) a 500 (planos pagos) | Ilimitado |
| **Limite de tamanho por fonte** | 500.000 palavras / 200 MB por fonte | Sem limite |
| **Preços** | Plano gratuito; Pro US$ 19,99/mês, Ultra US$ 249,99/mês | Gratuito e open source para auto-hospedar; a nuvem é paga por uso, com US$ 5 de crédito gratuito |
| **Suporte a LLMs** | Apenas Google Gemini | Mais de 100 LLMs via especificação da OpenAI e LiteLLM |
| **Modelos de embedding** | Apenas Google | Mais de 6.000 modelos de embedding, todos os principais rerankers |
| **LLMs locais / privados** | Não disponível | Suporte completo (vLLM, Ollama), seus dados continuam sendo seus |
| **Auto-hospedável** | Não | Sim, com uma linha de Docker ou Docker Compose completo |
| **Open source** | Não | Sim |
| **Fontes da base de conhecimento** | Google Drive, YouTube, sites | Upload de arquivos, Google Drive, OneDrive, Dropbox, sincronização de pasta local e páginas rastreadas |
| **Formatos de arquivo suportados** | PDFs, Docs, Slides, Sheets, CSV, Word, EPUB, imagens, URLs, YouTube | Mais de 50 formatos: documentos, imagens, vídeos via LlamaCloud, Unstructured ou Docling (local) |
| **Busca** | Busca semântica | Híbrida semântica + texto completo, com índices hierárquicos e fusão de rankings recíprocos |
| **Respostas com citações** | Sim | Sim, respostas citadas no estilo Perplexity |
| **Arquitetura agêntica** | Não | Sim, com [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview), incluindo planejamento, subagentes e acesso ao sistema de arquivos |
| **Automações e agentes de IA** | Não | Fluxos agendados, gatilhos por eventos e automações sem código criadas via chat, com gravação no Notion, Slack, Linear e Jira |
| **Multiplayer em tempo real** | Notebooks compartilhados com papéis Viewer/Editor (sem chat em tempo real) | RBAC com papéis Owner / Admin / Editor / Viewer, chat em tempo real e threads de comentários |
| **Geração de vídeo** | Video Overviews cinematográficos via Veo 3 (apenas Ultra) | Disponível (o NotebookLM é melhor aqui, em melhoria contínua) |
| **Geração de apresentações** | Slides mais bonitos, porém não editáveis | Apresentações editáveis baseadas em slides |
| **Geração de podcasts** | Audio Overviews com apresentadores e idiomas personalizáveis | Disponível com vários provedores de TTS (o NotebookLM é melhor aqui, em melhoria contínua) |
| **Aplicativo desktop** | Não | App nativo com General Assist, Quick Assist, Screenshot Assist e sincronização de pasta local |
| **Fontes por Notebook** | 50 (Grátis) a 600 (Ultra, $249.99/mês) | Ilimitadas |
| **Número de Notebooks** | 100 (Grátis) a 500 (planos pagos) | Ilimitados |
| **Limite de Tamanho da Fonte** | 500.000 palavras / 200MB por fonte | Sem limite |
| **Preços** | Nível gratuito disponível; Pro $19.99/mês, Ultra $249.99/mês | Gratuito e de código aberto, auto-hospedável na sua própria infra |
| **Suporte a LLM** | Apenas Google Gemini | 100+ LLMs via OpenAI spec e LiteLLM |
| **Modelos de Embeddings** | Apenas Google | 6.000+ modelos de embeddings, todos os principais rerankers |
| **LLMs Locais / Privados** | Não disponível | Suporte completo (vLLM, Ollama) - seus dados ficam com você |
| **Auto-Hospedável** | Não | Sim - Docker em um único comando ou Docker Compose completo |
| **Código Aberto** | Não | Sim |
| **Conectores Externos** | Google Drive, YouTube, sites | 27+ conectores - Mecanismos de busca, Google Drive, OneDrive, Dropbox, Slack, Teams, Jira, Notion, GitHub, Discord e [mais](#fontes-externas) |
| **Suporte a Formatos de Arquivo** | PDFs, Docs, Slides, Sheets, CSV, Word, EPUB, imagens, URLs web, YouTube | 50+ formatos - documentos, imagens, vídeos via LlamaCloud, Unstructured ou Docling (local) |
| **Busca** | Busca semântica | Busca Híbrida - Semântica + Texto completo com Índices Hierárquicos e Reciprocal Rank Fusion |
| **Respostas com Citações** | Sim | Sim - Respostas citadas no estilo Perplexity |
| **Arquitetura de Agentes** | Não | Sim - alimentado por [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) com planejamento, subagentes e acesso ao sistema de arquivos |
| **Multiplayer em Tempo Real** | Notebooks compartilhados com papéis de Visualizador/Editor (sem chat em tempo real) | RBAC com papéis de Proprietário / Admin / Editor / Visualizador, chat em tempo real e threads de comentários |
| **Geração de Vídeos** | Visões gerais cinemáticas via Veo 3 (apenas Ultra) | Disponível (NotebookLM é melhor aqui, melhorando ativamente) |
| **Geração de Apresentações** | Slides mais bonitos mas não editáveis | Cria apresentações editáveis baseadas em slides |
| **Geração de Podcasts** | Visões gerais em áudio com hosts e idiomas personalizáveis | Disponível com múltiplos provedores TTS (NotebookLM é melhor aqui, melhorando ativamente) |
| **Automações e Agentes de IA** | Não | Fluxos de trabalho de IA agendados, gatilhos por eventos em novos documentos e automações sem código criadas por chat com escrita de volta no Notion, Slack, Linear e Jira |
| **Aplicativo Desktop** | Não | Aplicativo nativo com General Assist, Quick Assist, Screenshot Assist e sincronização de pastas locais |
| **Extensão de Navegador** | Não | Extensão multi-navegador para salvar qualquer página web, incluindo páginas protegidas por autenticação |
<details>
<summary><b>Lista completa de Fontes Externas</b></summary>
<a id="fontes-externas"></a>
Mecanismos de Busca (SearXNG, Tavily, LinkUp, Baidu Search) · Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · Vídeos do YouTube · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian, e mais por vir.
</details>
## SOLICITAÇÕES DE FUNCIONALIDADES E FUTURO
## Pedidos de recursos e futuro
**O SurfSense está em desenvolvimento ativo.** Embora ainda não esteja pronto para produção, você pode nos ajudar a acelerar o processo.
Entre no [Discord do SurfSense](https://discord.gg/ejRNvftDp9) e ajude a moldar o futuro do SurfSense!
Junte-se ao [Discord do SurfSense](https://discord.gg/ejRNvftDp9) e ajude a moldar o futuro do SurfSense!
## Roadmap
Fique por dentro do nosso progresso de desenvolvimento e dos próximos recursos. Confira nosso roadmap público e contribua com suas ideias ou feedback:
Fique atualizado com nosso progresso de desenvolvimento e próximas funcionalidades!
Confira nosso roadmap público e contribua com suas ideias ou feedback:
**Discussão do roadmap:** [SurfSense 2026 Roadmap](https://github.com/MODSetter/SurfSense/discussions/565)
**Discussão do Roadmap:** [SurfSense 2026 Roadmap](https://github.com/MODSetter/SurfSense/discussions/565)
**Quadro Kanban:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
## Contribua
Todas as contribuições são bem-vindas, de estrelas e relatos de bugs a melhorias no backend. Veja o [CONTRIBUTING.md](CONTRIBUTING.md) para começar.
## Contribuir
Todas as contribuições são bem-vindas, desde estrelas e relatórios de bugs até melhorias no backend. Consulte [CONTRIBUTING.md](CONTRIBUTING.md) para começar.
Obrigado a todos os nossos Surfers:
@ -291,13 +310,13 @@ Obrigado a todos os nossos Surfers:
<img src="https://contrib.rocks/image?repo=MODSetter/SurfSense" />
</a>
## Histórico de estrelas
## Histórico de Stars
<a href="https://www.star-history.com/#MODSetter/SurfSense&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
<img alt="Gráfico do histórico de estrelas" src="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=MODSetter/SurfSense&type=Date" />
</picture>
</a>

View file

@ -1,4 +1,4 @@
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="SurfSense面向 AI 智能体的开源竞争情报平台" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
<a href="https://www.surfsense.com/"><img width="1584" height="396" alt="readme_banner" src="https://github.com/user-attachments/assets/9361ef58-1753-4b6e-b275-5020d8847261" /></a>
@ -20,272 +20,291 @@
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
# SurfSense:为你的 AI 智能体注入竞争情报能力
# SurfSense
SurfSense 是**面向 AI 智能体的开源竞争情报平台**。你的智能体可以通过一个 **REST API****MCP 服务器**,利用来自 **Reddit、YouTube、Google Maps、Google Search 和开放网络**的实时数据,监控竞争对手、追踪排名、倾听市场动态。定时和事件触发的智能体会把发现的内容转化为简报和预警,内置的知识库则让每一条发现都可搜索、可引用
NotebookLM 是目前最好、最实用的 AI 平台之一,但当你开始经常使用它时,你也会感受到它的局限性,总觉得还有不足之处
> [!NOTE]
> **📢 致我们的 NotebookLM 替代品用户**
>
> 在过去几个月里,我们把 SurfSense 打造成了针对个人知识的最佳通用研究智能体这段旅程为我们赢得了一个令我们由衷自豪的社区。如今Claude、OpenCode、Hermes、OpenClaw 等智能体工具已经证明智能体就是未来,通用研究正在成为每个有能力的智能体开箱即用的功能。而智能体仍然缺少的是**实时市场数据以及围绕它的工作流**,所以这正是我们全力投入的方向:成为标杆级的开源竞争情报智能体平台。
>
> **你所依赖的一切功能都不会消失。**你的知识库、带引用的对话、报告、播客、演示文稿、自动化以及协作聊天都会继续可用,自托管也依然免费且开源。完整公告请阅读[我们的更新日志](https://www.surfsense.com/changelog)。
1. 一个笔记本中可以添加的来源数量有限制。
2. 可以拥有的笔记本数量有限制。
3. 来源不能超过 500,000 个单词和 200MB。
4. 你被锁定在 Google 服务中LLM、使用模型等没有配置选项。
5. 有限的外部数据源和服务集成。
6. NotebookLM 代理专门针对学习和研究进行了优化,但你可以用源数据做更多事情。
7. 缺乏多人协作支持。
## 目录
...还有更多。
- [为什么智能体需要 SurfSense](#为什么智能体需要-surfsense)
- [你可以用 SurfSense 做什么?](#你可以用-surfsense-做什么)
- [实时数据连接器](#实时数据连接器)
- [快速开始](#快速开始)
- [开箱即用的其他能力](#开箱即用的其他能力)
- [SurfSense 对比 Google NotebookLM](#surfsense-对比-google-notebooklm)
- [路线图](#路线图)
- [参与贡献](#参与贡献)
**SurfSense 正是为了解决这些问题而生。** SurfSense 赋予你:
## 为什么智能体需要 SurfSense
- **控制你的数据流** - 保持数据私密和安全。
- **无数据限制** - 添加无限数量的来源和笔记本。
- **无供应商锁定** - 配置任何 LLM、图像、TTS 和 STT 模型。
- **25+ 外部数据源** - 从 Google Drive、OneDrive、Dropbox、Notion 和许多其他外部服务添加你的来源。
- **实时多人协作支持** - 在共享笔记本中轻松与团队成员协作。
- **AI 自动化与智能体** - 按计划运行 AI 智能体,或在文档进入文件夹的那一刻触发它们,然后将结果回写到 Notion、Slack、Linear 和 Drive。只需在聊天中描述即可创建无代码自动化。
- **桌面应用** - 通过 Quick Assist、General Assist、Screenshot Assist 和本地文件夹同步在任何应用程序中获得 AI 助手。
问任何一个有能力的智能体"竞争对手这周的定价是多少?"或者"自发布以来 Reddit 上对我们的评价如何?",它都找不到可信赖的数据来源。官方平台 API 要么有速率限制要么按企业级定价要么根本不存在而自建抓取管线又非常脆弱。SurfSense 正是为填补这一空白而生:
- **平台原生连接器**,每个都是返回结构化 JSON 的强类型 REST 端点。不用赌速率限制,也不用解析 HTML。
- **一个 MCP 服务器**,把每个连接器都作为原生工具(`surfsense_reddit_scrape``surfsense_google_search` 等)暴露给 Claude、Cursor 或任何智能体框架。
- **一套智能体运行框架**,而不只是原始数据:重试、结构化输出和额度计量都已内置,智能体可以从一个问题直达一份简报,无需你自己搭建管线。
- **开源且可自托管**,你的竞争研究数据始终留在你自己的基础设施上。
## 你可以用 SurfSense 做什么?
下面的每个用例都是 SurfSense 智能体如今能够端到端完成的真实任务,一条提示语或一个定时计划即可触发。
### 多连接器工作流
在一次智能体运行中串联多个连接器,最终得到一份带引用的简报。
- **发布影响力分析,覆盖所有平台** — "我们的竞争对手昨天发布了 v2。评估搜索、Reddit 和 YouTube 上的反应。"智能体会抓取搜索结果页、Reddit 帖子和 YouTube 评论,然后把三路信号汇总成一份发布影响力简报。
- **本地竞争对手拆解** — Google Maps 找到本地玩家网页爬虫读取他们的定价页面Google Search 展示谁在搜索中胜出,全部在一次运行中完成。
- **定时执行的竞争对手 360 全景** — 一条自动化每周串联四个连接器网站变更、排名变动、Reddit 舆情和 YouTube 反应。
### 竞争对手监控
- **定价监控** — 从竞争对手的定价页面提取每个套餐、价格和限制,汇总到一张表格,然后每天复查,任何变动即时预警。
- **产品与更新日志追踪** — 每周一爬取对手的更新日志、产品页和招聘页面,收到一份他们发布了什么的简报。
- **排名与广告监控** — 追踪你的市场真正看到的 Google 排名、付费广告和 AI Overview 引用,并逐日标记变动。
### B2B 潜在客户开发
- **本地商户线索** — 把一个品类加一个地区("圣何塞的汉堡店")变成一份线索清单,包含电话、网站、评分,以及从他们网站上提取的决策人联系方式。
- **团队名录与联系方式** — 爬取任意公司网站,提取完整团队名单,附带邮箱、社交账号和数据来源,导出为 CSV。
- **投资组合与市场地图** — 绘制一家投资机构的投资组合或整个品类的全景图,再为每家公司补充定价和联系方式。
### 品牌与市场舆情监听
- **Reddit 品牌监控** — 在买家坦诚发言的帖子里,倾听你的市场对你、你的竞争对手和你所在品类的真实评价。
- **YouTube 观众情绪分析** — 大规模拉取视频、字幕转录和评论,然后聚类分析观众在称赞什么、抱怨什么。
- **换用意向挖掘** — 找到正在积极寻找某竞争对手替代品的人群,并按他们的换用意愿排序。
### 市场调研
- **实时网络深度研究** — 智能体针对一个问题爬取数十个实时来源,并综合出一份带引用的答案,而不是过时的索引。
- **AI Overview 与 GEO 追踪** — 捕捉 Google 的 AI Overviews 何时回答了你市场的搜索,以及它们究竟引用了哪些来源。
- **带引用的简报与预警** — 智能体收集到的一切都会以简报和预警的形式汇入你的工作区,每条结论都附带可核查的来源。
### 把以上任何任务自动化,无需代码
自动化功能可以按计划或响应事件运行完整的智能体回合,然后把结果写回 Notion、Slack、Linear 和 Jira。用自然语言描述工作流SurfSense 就会替你构建。可以试试这些提示语:
- "盯住我们前 3 名竞争对手的定价页面,一旦套餐或价格变动就在 Slack 上提醒我。"
- "追踪 Reddit 和 YouTube 上对我们品牌的每一次提及,每天给我发一份摘要。"
- "监控我们前 10 个关键词的 Google 排名,按周标记下滑情况。"
- "每周一拉取我们门店以及竞争对手门店的最新 Google Maps 评论。"
- "每月生成一份竞争对手分析报告并保存到我的工作区。"
## 实时数据连接器
| 连接器 | 你的智能体能获得什么 | 了解更多 |
|---|---|---|
| **Reddit** | 帖子、评论和子版块信息流,不受官方 API 速率限制 | [Reddit Scraper API](https://www.surfsense.com/reddit) |
| **YouTube** | 视频、字幕转录和评论串,用于品牌和产品舆情监听 | [YouTube Scraper API](https://www.surfsense.com/youtube) |
| **Google Maps** | 地点、评分和评论,用于本地竞争对手和潜在客户调研 | [Google Maps Scraper API](https://www.surfsense.com/google-maps) |
| **Google Search** | 实时搜索结果页,用于排名追踪和市场监控 | [Google Search API](https://www.surfsense.com/google-search) |
| **Web Crawl** | 把开放网络上的任意页面转为干净、结构化的内容 | [Web Crawling API](https://www.surfsense.com/web-crawl) |
| **外部 MCP 连接器** | 将任意 MCP 服务器接入你的智能体Notion、Slack、Jira 等支持一键 OAuth | [External MCP Connectors](https://www.surfsense.com/external-mcp-connectors) |
计费采用按量付费:连接器按实际返回的条目计费,爬取按成功抓取的页面计费,失败的调用永不计费。自托管部署默认关闭计费。详见[定价](https://www.surfsense.com/pricing)。
## 快速开始
### 在代码中调用连接器
每个连接器都是一个 REST 端点,你可以用任何语言、凭借你的 SurfSense API 密钥来调用:
```bash
curl -X POST "$SURFSENSE_API_URL/workspaces/$WORKSPACE_ID/scrapers/reddit/scrape" \
-H "Authorization: Bearer $SURFSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"search_queries": ["your brand"],
"community": "webscraping",
"sort": "top",
"time_filter": "week"
}'
```
每个[连接器页面](https://www.surfsense.com/connectors)都提供 Python、JavaScript、Go、PHP、Ruby、Java 和 C# 的可直接复制粘贴的示例。
### 通过 MCP 把工具交给你的智能体
把 SurfSense MCP 服务器添加到 Claude、Cursor 或你自己的智能体框架:
```json
{
"mcpServers": {
"surfsense": {
"url": "https://mcp.surfsense.com/mcp",
"headers": { "Authorization": "Bearer ${SURFSENSE_API_KEY}" }
}
}
}
```
现在,你的智能体就可以把每个连接器当作原生工具来调用。完整工具列表请查看 [SurfSense MCP 服务器](https://www.surfsense.com/mcp-server) 页面,也可以通过 [`surfsense_mcp`](./surfsense_mcp) 在本地运行该服务器。
### 使用云端服务
访问 [surfsense.com](https://www.surfsense.com),登录后用自然语言向智能体索取实时市场数据。新账户自带 5 美元免费额度,无需订阅。
...更多功能即将推出。
### 免费自托管
在你自己的基础设施上运行整个平台,包括连接器、智能体、自动化和 MCP 服务器。自托管部署默认关闭计费,抓取、爬取和智能体运行只受你的硬件和你自带的模型密钥限制。
## 视频代理示例
**前置条件:**必须已安装并运行 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。
https://github.com/user-attachments/assets/012a7ffa-6f76-4f06-9dda-7632b470057a
Linux/macOS
## 播客代理示例
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## 如何使用 SurfSense
### Cloud
1. 访问 [surfsense.com](https://www.surfsense.com) 并登录。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/LoginFlowGif.gif" alt="登录" /></p>
2. 连接您的连接器并同步。启用定期同步以保持连接器数据更新。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="连接器" /></p>
3. 在连接器数据索引期间,上传文档。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="上传文档" /></p>
4. 一切索引完成后,尽管提问(使用场景):
**桌面应用**(在以下所有功能之外的原生附加功能,并非独立的功能集)
- General Assist通过全局快捷键从任意应用中即刻打开 SurfSense。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/general_assist.gif" alt="General Assist" /></p>
- Quick Assist在任意位置选中文本让 AI 解释、改写或对其执行操作。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
- Screenshot Assist截取屏幕上任意区域并就其中内容向 AI 提问。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/screenshot_assist.gif" alt="Screenshot Assist" /></p>
- Watch Local Folder将本地文件夹自动同步到你的知识库。非常适合 Obsidian 库。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/folder_watch.gif" alt="Watch Local Folder" /></p>
**成果工作室**
- AI Report Generator生成带引用的研究报告并导出为 PDF、DOCX、HTML、LaTeX、EPUB、ODT 或纯文本。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="AI Report Generator" /></p>
- AI Podcast Generator在 20 秒内将任意文档或文件夹转换为双主持人 AI 播客。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/PodcastGenGif.gif" alt="AI Podcast Generator" /></p>
- AI Presentation & Video Maker根据你的资料创建可编辑的幻灯片和带旁白的视频概览。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/video_gen_gif.gif" alt="AI Presentation and Video Maker" /></p>
- AI Image Generator直接从你的聊天和文档生成高质量图像。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ImageGenGif.gif" alt="AI Image Generator" /></p>
- AI Resume Builder根据任意职位描述定制你现有的简历顺利通过 ATS。
可以试试这样的提示:
- “根据这份职位描述定制我的简历,让它通过 ATS 并赢得面试。”
- “匹配这份招聘启事中的关键词,为 ATS 优化我的简历。”
- “重写我的简历要点,突出这个岗位所需要的技能。”
- “将我的简历与这份职位描述对比,列出需要改进的差距。”
- “根据我的简历和这份职位描述,写一封相匹配的求职信。”
**搜索与聊天**
- Chat With Your PDFs & Docs跨所有文件提问并获得带内联引用的答案。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="Chat With Your PDFs and Docs" /></p>
- AI Search With Citations在整个知识库中进行语义与关键词的混合搜索。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BSNCGif.gif" alt="AI Search With Citations" /></p>
- Collaborative AI Chat与团队实时协作处理 AI 对话。
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="Collaborative AI Chat" /></p>
- Comments & Mentions在任意 AI 消息上评论并 @ 你的队友。
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="Comments and Mentions" /></p>
**连接器与集成**
- Connect & Sync Your Tools将 Notion、Slack、Google Drive、Gmail、GitHub、Linear 等 25+ 数据源同步为一个可搜索的语料库。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ConnectorFlowGif.gif" alt="Connect and Sync Your Tools" /></p>
- Chat With Uploaded Files上传 PDF、Office 文档、图像和音频。即刻可搜索。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/DocUploadGif.gif" alt="Chat With Uploaded Files" /></p>
- Connector Write-Back让智能体将结果回写到 Notion、Slack、Linear 和 Drive。
可以试试这样的提示:
- “把这份研究摘要发布到我的 Notion 工作区。”
- “把这些会议行动项发送到我们的团队 Slack 频道。”
- “根据这份缺陷报告创建一个 Jira 工单。”
- “根据这个功能需求在 Linear 中创建一个 issue。”
- “把这份生成的报告作为文档保存到 Google Drive。”
- Obsidian & Knowledge Base Sync让你的 Obsidian 库与个人知识库保持同步。
**自动化**
- Scheduled AI Workflows按计划运行智能体每日简报、每周摘要、周期性报告。
可以试试这样的提示:
- “每天早上把我知识库中新增文档的每日简报发邮件给我。”
- “每周五根据我的 Slack 和 Gmail 生成一份每周状态报告。”
- “每月运行一次竞争对手分析报告并保存到我的工作区。”
- “把我的 GitHub 和 Linear 活动汇总成一份每日站会更新。”
- “针对我关注的主题创建一份周期性的每周研究报告。”
- Event-Triggered Automations在文档进入文件夹的那一刻触发智能体并将结果发布到你的工具中。
可以试试这样的提示:
- “当一个 PDF 进入我的 Research 文件夹时,生成一份带引用的 AI 摘要。”
- “当新增会议记录时,把它整理成带行动项的会议纪要。”
- “当上传发票时,把供应商、总额和到期日提取到一张表格中。”
- “当一份合同进入我的 Legal 文件夹时,标记关键条款和续约日期。”
- “当一份简历加入 Candidates 时,根据职位描述对其进行筛选。”
- Chat-Built Automations用通俗的语言描述一个自动化SurfSense 就会为你构建它。
可以试试这样的提示:
- “创建一个 AI 智能体,每天早上把新增 Notion 页面的摘要发邮件给我。”
- “创建一个无代码自动化,每周把研究摘要发布到 Slack。”
- “设置一个 AI 笔记助手,把新增会议记录整理成纪要。”
- “创建一个工作流,从会议记录中提取行动项并指派负责人。”
- “自动化一份来自我的 Gmail 和 Google Drive 的每日邮件简报。”
### 自托管
在您自己的基础设施上运行 SurfSense实现完全的数据控制和隐私保护。
**前置条件:** 需要安装并运行 [Docker Desktop](https://www.docker.com/products/docker-desktop/)。
#### Linux/MacOS 用户:
```bash
curl -fsSL https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.sh | bash
```
Windows
#### Windows 用户
```bash
```powershell
irm https://raw.githubusercontent.com/MODSetter/SurfSense/main/docker/scripts/install.ps1 | iex
```
安装脚本会自动配置 [Watchtower](https://github.com/nicholas-fedor/watchtower) 以实现每日自动更新。如需跳过,请加上 `--no-watchtower` 参数。关于 Docker Compose、手动安装及其他部署方式请参阅[文档](https://www.surfsense.com/docs/)。
安装脚本会自动配置 [Watchtower](https://github.com/nicholas-fedor/watchtower) 以实现每日自动更新。如需跳过,请`--no-watchtower` 参数。
## 开箱即用的其他能力
如需 Docker Compose、手动安装及其他部署方式请查看[文档](https://www.surfsense.com/docs/)。
让 SurfSense 成为领先的开源 NotebookLM 替代品的那套研究工作区依然都在,而且你的智能体收集到的一切都会汇入其中。
### 桌面应用
**知识库**
SurfSense 还提供桌面应用,将 AI 助手带到您计算机上的每个应用程序中。从[最新版本](https://github.com/MODSetter/SurfSense/releases/latest)下载。
- 上传 PDF、Office 文档、图片和音频,或同步 **Google Drive、OneDrive 和 Dropbox**。支持 50 多种文件格式。
- 混合语义与全文搜索,提供 Perplexity 风格的带引用回答。
- AI 文件整理功能按来源、日期和主题自动归类文档。
桌面应用包含以下强大功能:
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/BQnaGif_compressed.gif" alt="与你的 PDF 和文档对话" /></p>
- **General Assist** — 通过全局快捷键从任何应用程序即时启动 SurfSense。
- **Quick Assist** — 在任何位置选中文本,然后让 AI 解释、改写或对其执行操作。
- **Screenshot Assist** — 在屏幕上框选区域并附加到聊天,让回复基于您的知识库。
- **Watch Local Folder** — 监视本地文件夹,自动将文件更改同步到您的知识库。**Pro tip** 将其指向您的 Obsidian vault让笔记在 SurfSense 中随时可搜索。
**成果工作室**
所有功能均基于您选择的搜索空间运行,确保回答始终以您自己的数据为依据。
- AI 报告生成器,可导出为 PDF、DOCX、HTML、LaTeX、EPUB、ODT 或纯文本。
- 20 秒内基于任意文档或文件夹生成双主持人 AI 播客。
- 可编辑的幻灯片、带旁白的视频概览以及 AI 图像生成。
### 如何实时协作Beta
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/ReportGenGif_compressed.gif" alt="AI 报告生成器" /></p>
**团队协作**
- 支持评论和提及的实时协作 AI 聊天。
- 基于角色的访问控制RBAC提供所有者、管理员、编辑者和查看者角色。
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="协作 AI 聊天" /></p>
**桌面应用**
在电脑上的每个应用中获得原生 AI 辅助。从[最新版本](https://github.com/MODSetter/SurfSense/releases/latest)下载。
- **General Assist**:通过全局快捷键在任意应用中唤起 SurfSense。
- **Quick Assist**:在任意位置选中文本,让 AI 解释、改写或据此执行操作。
- **Screenshot Assist**:截取屏幕上任意区域,向 AI 提问。
- **Watch Local Folder**:把本地文件夹自动同步到知识库。将它指向你的 Obsidian 仓库,让笔记随时可搜索。
<p align="center"><img src="surfsense_web/public/homepage/hero_tutorial/quick_assist.gif" alt="Quick Assist" /></p>
**无供应商锁定**
- 通过 OpenAI 规范和 LiteLLM 支持 100 多种 LLM包括 GPT-5.5、Claude Sonnet 5 和 Gemini 3.1 Pro。
- 支持 6,000 多种嵌入模型和所有主流重排序器。
- 完整支持本地和私有 LLMvLLM、Ollama你的数据始终属于你。
## 视频智能体示例
https://github.com/user-attachments/assets/012a7ffa-6f76-4f06-9dda-7632b470057a
## 播客智能体示例
https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
## 如何进行实时协作Beta
1. 进入成员管理页面并创建邀请。
1. 前往成员管理页面并创建邀请。
<p align="center"><img src="https://github.com/user-attachments/assets/40ed7683-5aa6-48a0-a3df-00575528c392" alt="邀请成员" /></p>
2. 队友加入后,该工作区即变为共享。
2. 队友加入后,该 SearchSpace 变为共享。
<p align="center"><img src="https://github.com/user-attachments/assets/ea4e1057-4d2b-4fd2-9ca0-cd19286a285e" alt="邀请加入流程" /></p>
3. 将聊天设为共享,即可与团队实时协作,并通过评论标记队友。
3. 将聊天设为共享。
<p align="center"><img src="https://github.com/user-attachments/assets/17b93904-0888-4c3a-ac12-51a24a8ea26a" alt="设为共享聊天" /></p>
4. 您的团队现在可以实时聊天。
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeChatGif.gif" alt="实时聊天" /></p>
5. 添加评论以标记队友。
<p align="center"><img src="surfsense_web/public/homepage/hero_realtime/RealTimeCommentsFlow.gif" alt="实时评论" /></p>
## SurfSense 对比 Google NotebookLM
还在把我们当作 NotebookLM 替代品来比较?这里是坦诚的对比。
## SurfSense vs Google NotebookLM
| 功能 | Google NotebookLM | SurfSense |
|---------|-------------------|-----------|
| **面向智能体的实时市场数据** | 无 | 通过 REST API 和 MCP 提供 Reddit、YouTube、Google Maps、Google Search 和网页爬取连接器 |
| **MCP 服务器** | 无 | 每个连接器都作为原生智能体工具暴露,还可自带 MCP 服务器并使用一键 OAuth 应用 |
| **每个笔记本的来源数** | 50 个(免费版)至 600 个Ultra 版249.99 美元/月) | 无限制 |
| **笔记本数量** | 100 个(免费版)至 500 个(付费档位) | 无限制 |
| **来源大小限制** | 每个来源 50 万字 / 200MB | 无限制 |
| **定价** | 免费档Pro 19.99 美元/月Ultra 249.99 美元/月 | 自托管免费且开源;云端按量付费,附赠 5 美元免费额度 |
| **LLM 支持** | 仅 Google Gemini | 通过 OpenAI 规范和 LiteLLM 支持 100 多种 LLM |
| **嵌入模型** | 仅 Google | 6,000 多种嵌入模型,所有主流重排序器 |
| **本地 / 私有 LLM** | 不支持 | 完整支持vLLM、Ollama你的数据始终属于你 |
| **可自托管** | 否 | 是Docker 一行命令或完整 Docker Compose |
| **每个笔记本的来源数** | 50免费到 600Ultra$249.99/月) | 无限制 |
| **笔记本数量** | 100免费到 500付费方案 | 无限制 |
| **来源大小限制** | 500,000 词 / 200MB 每个来源 | 无限制 |
| **定价** | 免费版可用Pro $19.99/月Ultra $249.99/月 | 免费开源,在自己的基础设施上自托管 |
| **LLM 支持** | 仅 Google Gemini | 100+ LLM通过 OpenAI spec 和 LiteLLM |
| **嵌入模型** | 仅 Google | 6,000+ 嵌入模型,所有主流重排序器 |
| **本地 / 私有 LLM** | 不可用 | 完整支持vLLM、Ollama- 您的数据由您掌控 |
| **可自托管** | 否 | 是 - Docker 一行命令或完整 Docker Compose |
| **开源** | 否 | 是 |
| **知识库来源** | Google Drive、YouTube、网站 | 文件上传、Google Drive、OneDrive、Dropbox、本地文件夹同步以及爬取的网页 |
| **文件格式支持** | PDF、Docs、Slides、Sheets、CSV、Word、EPUB、图片、网页 URL、YouTube | 50 多种格式:文档、图片、视频,通过 LlamaCloud、Unstructured 或 Docling本地解析 |
| **搜索** | 语义搜索 | 混合语义 + 全文搜索,带分层索引和倒数排名融合 |
| **带引用的回答** | 有 | 有Perplexity 风格的引用回答 |
| **智能体架构** | 无 | 有,由 [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) 驱动,具备规划、子智能体和文件系统访问能力 |
| **AI 自动化与智能体** | 无 | 定时工作流、事件触发以及通过聊天构建的无代码自动化,可写回 Notion、Slack、Linear 和 Jira |
| **实时多人协作** | 共享笔记本,仅有查看者/编辑者角色(无实时聊天) | RBAC 提供所有者 / 管理员 / 编辑者 / 查看者角色,支持实时聊天和评论串 |
| **视频生成** | 通过 Veo 3 生成电影级视频概览(仅 Ultra 版) | 已提供(此项 NotebookLM 更强,我们正在积极改进) |
| **演示文稿生成** | 幻灯片更美观但不可编辑 | 可编辑的幻灯片式演示文稿 |
| **播客生成** | 音频概览,支持自定义主持人和语言 | 已提供,支持多种 TTS 服务商(此项 NotebookLM 更强,我们正在积极改进) |
| **桌面应用** | 无 | 原生应用,包含 General Assist、Quick Assist、Screenshot Assist 和本地文件夹同步 |
| **外部连接器** | Google Drive、YouTube、网站 | 27+ 连接器 - 搜索引擎、Google Drive、OneDrive、Dropbox、Slack、Teams、Jira、Notion、GitHub、Discord 等[更多](#外部数据源) |
| **文件格式支持** | PDF、Docs、Slides、Sheets、CSV、Word、EPUB、图像、网页 URL、YouTube | 50+ 格式 - 文档、图像、视频,通过 LlamaCloud、Unstructured 或 Docling本地 |
| **搜索** | 语义搜索 | 混合搜索 - 语义 + 全文搜索,结合层次化索引和倒数排名融合 |
| **引用回答** | 是 | 是 - Perplexity 风格的引用回答 |
| **代理架构** | 否 | 是 - 基于 [LangChain Deep Agents](https://docs.langchain.com/oss/python/deepagents/overview) 构建,支持规划、子代理和文件系统访问 |
| **实时多人协作** | 共享笔记本,支持查看者/编辑者角色(无实时聊天) | RBAC 角色控制(所有者/管理员/编辑者/查看者),实时聊天和评论线程 |
| **视频生成** | 通过 Veo 3 的电影级视频概览(仅 Ultra | 可用NotebookLM 在此方面更好,正在积极改进) |
| **演示文稿生成** | 更美观的幻灯片但不可编辑 | 创建可编辑的幻灯片式演示文稿 |
| **播客生成** | 可自定义主持人和语言的音频概览 | 可用,支持多种 TTS 提供商NotebookLM 在此方面更好,正在积极改进) |
| **AI 自动化与智能体** | 否 | 定时 AI 工作流、新文档的事件触发,以及通过聊天构建的无代码自动化,支持回写到 Notion、Slack、Linear 和 Jira |
| **桌面应用** | 否 | 原生应用,包含 General Assist、Quick Assist、Screenshot Assist 和本地文件夹同步 |
| **浏览器扩展** | 否 | 跨浏览器扩展,保存任何网页,包括需要身份验证的页面 |
<details>
<summary><b>外部数据源完整列表</b></summary>
<a id="外部数据源"></a>
搜索引擎SearXNG、Tavily、LinkUp、Baidu Search· Google Drive · OneDrive · Dropbox · Slack · Microsoft Teams · Linear · Jira · ClickUp · Confluence · BookStack · Notion · Gmail · YouTube 视频 · GitHub · Discord · Airtable · Google Calendar · Luma · Circleback · Elasticsearch · Obsidian更多即将推出。
</details>
## 功能请求与未来规划
**SurfSense 正在积极开发中。**虽然它尚未达到生产就绪状态,但你可以帮助我们加快进度。
加入 [SurfSense Discord](https://discord.gg/ejRNvftDp9),一起塑造 SurfSense 的未来!
**SurfSense 正在积极开发中。** 虽然它还未达到生产就绪状态,但您可以帮助我们加快进度。
加入 [SurfSense Discord](https://discord.gg/ejRNvftDp9) 一起塑造 SurfSense 的未来!
## 路线图
随时了解我们的开发进度和即将推出的功能。查看我们的公开路线图,贡献你的想法或反馈:
随时了解我们的开发进度和即将推出的功能!
查看我们的公开路线图并贡献您的想法或反馈:
**路线图讨论:** [SurfSense 2026 Roadmap](https://github.com/MODSetter/SurfSense/discussions/565)
**看板:** [SurfSense Project Board](https://github.com/users/MODSetter/projects/3)
## 参与贡献
欢迎一切形式的贡献,从点星标、报告缺陷到后端改进。请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 开始参与。
## 贡献
感谢所有 Surfer
欢迎所有贡献,从 Star 和 Bug 报告到后端改进。请参阅 [CONTRIBUTING.md](CONTRIBUTING.md) 开始贡献。
感谢所有 Surfers:
<a href="https://github.com/MODSetter/SurfSense/graphs/contributors">
<img src="https://contrib.rocks/image?repo=MODSetter/SurfSense" />

View file

@ -1 +1 @@
0.0.31
0.0.28

View file

@ -30,14 +30,6 @@ SECRET_KEY=replace_me_with_a_random_string
# Auth type: LOCAL (email/password) or GOOGLE (OAuth)
AUTH_TYPE=LOCAL
# Cloud only: set COOKIE_DOMAIN=.surfsense.com so api., zero., and app
# subdomains all receive the same first-party session cookie. Leave empty for
# self-hosted Docker where Caddy serves a single origin.
# COOKIE_DOMAIN=
# Deployment mode: self-hosted enables local filesystem connectors; cloud hides them.
DEPLOYMENT_MODE=self-hosted
# Allow new user registrations (TRUE or FALSE)
# REGISTRATION_ENABLED=TRUE
@ -51,47 +43,51 @@ ETL_SERVICE=DOCLING
EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
# ------------------------------------------------------------------------------
# How You Access SurfSense
# Ports (change to avoid conflicts with other services on your machine)
# ------------------------------------------------------------------------------
# One public URL. Browser traffic stays same-origin and Caddy routes internally.
SURFSENSE_PUBLIC_URL=http://localhost:3929
# BACKEND_PORT=8929
# FRONTEND_PORT=3929
# ZERO_CACHE_PORT=5929
# SEARXNG_PORT=8888
# FLOWER_PORT=5555
# ==============================================================================
# DEV COMPOSE ONLY (docker-compose.dev.yml)
# You only need them only if you are running `docker-compose.dev.yml`.
# ==============================================================================
# -- pgAdmin (database GUI) --
# PGADMIN_PORT=5050
# PGADMIN_DEFAULT_EMAIL=admin@surfsense.com
# PGADMIN_DEFAULT_PASSWORD=surfsense
# -- Redis exposed port (dev only; Redis is internal-only in prod) --
# REDIS_PORT=6379
# -- WhatsApp bridge exposed port (dev/hybrid only; prod keeps it Docker-internal) --
# WHATSAPP_BRIDGE_PORT=9929
# -- Frontend Build Args --
# In dev, the frontend is built from source and these are passed as build args.
# In prod, they are automatically derived from AUTH_TYPE, ETL_SERVICE, and the port settings above.
# NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL
# NEXT_PUBLIC_ETL_SERVICE=DOCLING
# NEXT_PUBLIC_DEPLOYMENT_MODE=self-hosted
# ------------------------------------------------------------------------------
# Public Ports
# Custom Domain / Reverse Proxy
# ------------------------------------------------------------------------------
# Production Docker exposes only Caddy to your machine. Caddy then routes
# frontend, backend, and zero-cache traffic internally.
# ONLY set these if you are serving SurfSense on a real domain via a reverse
# proxy (e.g. Caddy, Nginx, Cloudflare Tunnel).
# For standard localhost deployments, leave all of these commented out.
# they are automatically derived from the port settings above.
#
# Local default: LISTEN_HTTP_PORT=3929
# Domain default: LISTEN_HTTP_PORT=80 and LISTEN_HTTPS_PORT=443
LISTEN_HTTP_PORT=3929
LISTEN_HTTPS_PORT=443
# ------------------------------------------------------------------------------
# Custom Domain / HTTPS
# ------------------------------------------------------------------------------
# Leave SURFSENSE_SITE_ADDRESS as :80 for local HTTP.
# Set it to your domain to enable automatic HTTPS:
# SURFSENSE_SITE_ADDRESS=surf.example.com
# CERT_EMAIL=you@example.com
SURFSENSE_SITE_ADDRESS=:80
CERT_EMAIL=
# ------------------------------------------------------------------------------
# Advanced Reverse Proxy Settings
# ------------------------------------------------------------------------------
# Usually do not change these. They are for custom certificate setup, CDNs/load
# balancers, trusted proxy IPs, or changing upload limits.
#
# CERT_ACME_CA=https://acme-v02.api.letsencrypt.org/directory
# CERT_ACME_DNS=
# If a CDN/load balancer sits in front of Caddy, narrow this to that proxy's CIDRs.
# TRUSTED_PROXIES=0.0.0.0/0
# SURFSENSE_MAX_BODY_SIZE=5GB
#
# Browser API and Zero URLs are same-origin relative behind bundled Caddy.
# Next.js server-side calls use Docker DNS through SURFSENSE_BACKEND_INTERNAL_URL
# set internally by docker-compose.yml. Usually do not override it.
# NEXT_FRONTEND_URL=https://app.yourdomain.com
# BACKEND_URL=https://api.yourdomain.com
# NEXT_PUBLIC_FASTAPI_BACKEND_URL=https://api.yourdomain.com
# NEXT_PUBLIC_ZERO_CACHE_URL=https://zero.yourdomain.com
# FASTAPI_BACKEND_INTERNAL_URL=http://backend:8000
# ------------------------------------------------------------------------------
# Zero-cache (real-time sync)
@ -112,9 +108,10 @@ CERT_EMAIL=
# Sync worker tuning. zero-cache defaults ZERO_NUM_SYNC_WORKERS to the number
# of CPU cores, which can exceed the connection pool limits on high-core machines.
# Each sync worker needs at least 1 connection from both the UPSTREAM and CVR pools.
# Keep ZERO_UPSTREAM_MAX_CONNS and ZERO_CVR_MAX_CONNS greater than or equal to
# ZERO_NUM_SYNC_WORKERS.
# Each sync worker needs at least 1 connection from both the UPSTREAM and CVR
# pools, so these constraints must hold:
# ZERO_UPSTREAM_MAX_CONNS >= ZERO_NUM_SYNC_WORKERS
# ZERO_CVR_MAX_CONNS >= ZERO_NUM_SYNC_WORKERS
# Default of 4 workers is sufficient for self-hosted / personal use.
# ZERO_NUM_SYNC_WORKERS=4
# ZERO_UPSTREAM_MAX_CONNS=20
@ -135,24 +132,11 @@ CERT_EMAIL=
# Override when running the frontend outside Docker:
# ZERO_QUERY_URL=http://host.docker.internal:3000/api/zero/query
# ZERO_MUTATE_URL=http://host.docker.internal:3000/api/zero/mutate
# Override for custom domain only when zero-cache is not in the bundled Docker network:
# ZERO_QUERY_URL=https://surf.example.com/api/zero/query
# ZERO_MUTATE_URL=https://surf.example.com/api/zero/mutate
# Override for custom domain:
# ZERO_QUERY_URL=https://app.yourdomain.com/api/zero/query
# ZERO_MUTATE_URL=https://app.yourdomain.com/api/zero/mutate
# ZERO_QUERY_URL=http://frontend:3000/api/zero/query
# ZERO_MUTATE_URL=http://frontend:3000/api/zero/mutate
#
# Forward browser session cookies from zero-cache to the query route. Keep this
# enabled before switching the web app to cookie-only auth.
# ZERO_QUERY_FORWARD_COOKIES=true
#
# Optional shared secret for the zero-cache -> /api/zero/query hop. Set the same
# value on zero-cache and the frontend. When unset, the query route accepts the
# request for backward-compatible rollout.
# ZERO_QUERY_API_KEY=
#
# Bounds for auth revocation and RBAC membership changes on already-open sockets.
# ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=60
# ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=60
# ------------------------------------------------------------------------------
# Database (defaults work out of the box, change for security)
@ -238,74 +222,73 @@ STT_SERVICE=local/base
# ------------------------------------------------------------------------------
# -- Google Connectors --
# GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/calendar/connector/callback
# GOOGLE_GMAIL_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/gmail/connector/callback
# GOOGLE_DRIVE_REDIRECT_URI=http://localhost:3929/api/v1/auth/google/drive/connector/callback
# GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback
# GOOGLE_GMAIL_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/gmail/connector/callback
# GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback
# -- Notion --
# NOTION_CLIENT_ID=
# NOTION_CLIENT_SECRET=
# NOTION_REDIRECT_URI=http://localhost:3929/api/v1/auth/notion/connector/callback
# NOTION_REDIRECT_URI=http://localhost:8000/api/v1/auth/notion/connector/callback
# -- Slack --
# SLACK_CLIENT_ID=
# SLACK_CLIENT_SECRET=
# SLACK_REDIRECT_URI=http://localhost:3929/api/v1/auth/slack/connector/callback
# SLACK_REDIRECT_URI=http://localhost:8000/api/v1/auth/slack/connector/callback
# -- Discord --
# DISCORD_CLIENT_ID=
# DISCORD_CLIENT_SECRET=
# DISCORD_REDIRECT_URI=http://localhost:3929/api/v1/auth/discord/connector/callback
# DISCORD_REDIRECT_URI=http://localhost:8000/api/v1/auth/discord/connector/callback
# DISCORD_BOT_TOKEN=
# -- Atlassian (Jira & Confluence) --
# ATLASSIAN_CLIENT_ID=
# ATLASSIAN_CLIENT_SECRET=
# JIRA_REDIRECT_URI=http://localhost:3929/api/v1/auth/jira/connector/callback
# CONFLUENCE_REDIRECT_URI=http://localhost:3929/api/v1/auth/confluence/connector/callback
# JIRA_REDIRECT_URI=http://localhost:8000/api/v1/auth/jira/connector/callback
# CONFLUENCE_REDIRECT_URI=http://localhost:8000/api/v1/auth/confluence/connector/callback
# -- Linear --
# LINEAR_CLIENT_ID=
# LINEAR_CLIENT_SECRET=
# LINEAR_REDIRECT_URI=http://localhost:3929/api/v1/auth/linear/connector/callback
# LINEAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/linear/connector/callback
# -- ClickUp --
# CLICKUP_CLIENT_ID=
# CLICKUP_CLIENT_SECRET=
# CLICKUP_REDIRECT_URI=http://localhost:3929/api/v1/auth/clickup/connector/callback
# CLICKUP_REDIRECT_URI=http://localhost:8000/api/v1/auth/clickup/connector/callback
# -- Airtable --
# AIRTABLE_CLIENT_ID=
# AIRTABLE_CLIENT_SECRET=
# AIRTABLE_REDIRECT_URI=http://localhost:3929/api/v1/auth/airtable/connector/callback
# AIRTABLE_REDIRECT_URI=http://localhost:8000/api/v1/auth/airtable/connector/callback
# -- Microsoft OAuth (Teams & OneDrive) --
# MICROSOFT_CLIENT_ID=
# MICROSOFT_CLIENT_SECRET=
# TEAMS_REDIRECT_URI=http://localhost:3929/api/v1/auth/teams/connector/callback
# ONEDRIVE_REDIRECT_URI=http://localhost:3929/api/v1/auth/onedrive/connector/callback
# TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback
# ONEDRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/onedrive/connector/callback
# -- Dropbox --
# DROPBOX_APP_KEY=
# DROPBOX_APP_SECRET=
# DROPBOX_REDIRECT_URI=http://localhost:3929/api/v1/auth/dropbox/connector/callback
# DROPBOX_REDIRECT_URI=http://localhost:8000/api/v1/auth/dropbox/connector/callback
# -- Composio --
# COMPOSIO_API_KEY=
# COMPOSIO_ENABLED=TRUE
# COMPOSIO_REDIRECT_URI=http://localhost:3929/api/v1/auth/composio/connector/callback
# COMPOSIO_REDIRECT_URI=http://localhost:8000/api/v1/auth/composio/connector/callback
# ------------------------------------------------------------------------------
# Messaging Channels (optional)
# ------------------------------------------------------------------------------
# Configure only the external chat channels you want to use.
# GATEWAY_ENABLED=TRUE
# -- Telegram --
# TELEGRAM_SHARED_BOT_TOKEN=
# TELEGRAM_SHARED_BOT_USERNAME=
# TELEGRAM_WEBHOOK_SECRET=
# GATEWAY_BASE_URL=http://localhost:3929
# GATEWAY_BASE_URL=http://localhost:8929
# GATEWAY_TELEGRAM_INTAKE_MODE=webhook
# -- WhatsApp --
@ -324,14 +307,24 @@ STT_SERVICE=local/base
#
# GATEWAY_SLACK_ENABLED=FALSE
# GATEWAY_SLACK_SIGNING_SECRET=
# GATEWAY_SLACK_REDIRECT_URI=http://localhost:3929/api/v1/gateway/slack/callback
# GATEWAY_SLACK_REDIRECT_URI=http://localhost:8929/api/v1/gateway/slack/callback
# -- Discord --
# Uses DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, and DISCORD_BOT_TOKEN from the
# Discord connector section.
#
# GATEWAY_DISCORD_ENABLED=FALSE
# GATEWAY_DISCORD_REDIRECT_URI=http://localhost:3929/api/v1/gateway/discord/callback
# GATEWAY_DISCORD_REDIRECT_URI=http://localhost:8929/api/v1/gateway/discord/callback
# ------------------------------------------------------------------------------
# SearXNG (bundled web search, works out of the box with no config needed)
# ------------------------------------------------------------------------------
# SearXNG provides web search to all search spaces automatically.
# To access the SearXNG UI directly: http://localhost:8888
# To disable the service entirely: docker compose up --scale searxng=0
# To point at your own SearXNG instance instead of the bundled one:
# SEARXNG_DEFAULT_HOST=http://your-searxng:8080
# SEARXNG_SECRET=surfsense-searxng-secret
# ------------------------------------------------------------------------------
# Daytona Sandbox (optional cloud code execution for the deep agent)
@ -402,6 +395,7 @@ SURFSENSE_ENABLE_TOOL_CALL_REPAIR=true
SURFSENSE_ENABLE_BUSY_MUTEX=true
SURFSENSE_ENABLE_SKILLS=true
SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=true
SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE=true
SURFSENSE_ENABLE_ACTION_LOG=true
SURFSENSE_ENABLE_REVERT_ROUTE=true
SURFSENSE_ENABLE_PERMISSION=true
@ -425,31 +419,6 @@ SURFSENSE_ENABLE_DOOM_LOOP=true
# ETL_CREDIT_BILLING_ENABLED=FALSE
# MICROS_PER_PAGE=1000
# Debit the credit wallet per *successful* web crawl. Default FALSE keeps
# crawling effectively free for self-hosted installs. Price is config-driven:
# WEB_CRAWL_MICROS_PER_SUCCESS = round(USD_per_1000_crawls * 1_000)
# 2000 == $2/1000 (default) | 1000 == $1/1000. Captcha solves bill as a
# separate per-attempt unit (independent flag).
# WEB_CRAWL_CREDIT_BILLING_ENABLED=FALSE
# WEB_CRAWL_MICROS_PER_SUCCESS=2000
# WEB_CRAWL_CAPTCHA_BILLING_ENABLED=FALSE
# WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE=3000
# Debit the credit wallet per *item returned* by the platform-native scrapers
# (Reddit, Google Search, Google Maps, YouTube). Default FALSE keeps scraping
# effectively free for self-hosted installs. Each rate is micro-USD per item,
# config-driven: <KEY> = round(USD_per_1000_items * 1_000). Defaults sit
# at/above Apify's first-party actor rates (we charge no subscription tiers,
# start fees, or separate proxy/compute billing). google_maps.scrape is
# dual-metered (places + attached reviews).
# PLATFORM_SCRAPE_BILLING_ENABLED=FALSE
# REDDIT_SCRAPE_MICROS_PER_ITEM=3500
# GOOGLE_SEARCH_MICROS_PER_SERP=5500
# GOOGLE_MAPS_MICROS_PER_PLACE=3500
# GOOGLE_MAPS_MICROS_PER_REVIEW=1500
# YOUTUBE_MICROS_PER_VIDEO=2500
# YOUTUBE_MICROS_PER_COMMENT=1500
# Safety ceiling on per-call premium reservation, in micro-USD ($1.00 default).
# QUOTA_MAX_RESERVE_MICROS=1000000
@ -482,64 +451,9 @@ NOLOGIN_MODE_ENABLED=FALSE
# Connector indexing lock TTL in seconds (default: 28800 = 8 hours)
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
# Proxy provider selection: "custom" (default) or "dataimpulse".
# PROXY_PROVIDER=custom
# Proxy endpoint(s), shared across providers. PROXY_URL is a single full URL used
# by every provider (for "dataimpulse", country is a "__cr.<country>" username
# suffix the provider parses for geoip). PROXY_URLS is a comma-separated pool the
# "custom" provider rotates client-side. Leave unset to disable proxying.
# PROXY_URL=http://user:pass@host:port
# PROXY_URLS=http://user:pass@host1:port,http://user:pass@host2:port
# Captcha solving — last-resort bypass tier via captchatools. Only fires on the
# stealth browser tier when a sitekey is detected AND the flag is TRUE.
# Cloudflare Turnstile is already solved free in-framework. Off by default.
# NOTE: automated solving may violate a target site's ToS — opt-in, public
# data only. See surfsense_backend/.env.example for the full option docs.
# CAPTCHA_SOLVING_ENABLED=FALSE
# CAPTCHA_SOLVER_PROVIDER=capsolver
# CAPTCHA_SOLVER_API_KEY=
# CAPTCHA_MAX_ATTEMPTS_PER_URL=1
# CAPTCHA_SOLVE_TIMEOUT_S=120
# CAPTCHA_TYPE_DEFAULT=v2
# CAPTCHA_V3_MIN_SCORE=0.7
# CAPTCHA_V3_ACTION=verify
# Stealth hardening levers on the stealth browser tier. Defaults preserve
# current behavior; see surfsense_backend/.env.example for per-flag docs.
# CRAWL_GEOIP_MATCH_ENABLED=FALSE
# CRAWL_BLOCK_WEBRTC=TRUE
# CRAWL_HIDE_CANVAS=FALSE
# CRAWL_GOOGLE_SEARCH_REFERER=TRUE
# CRAWL_DNS_OVER_HTTPS=FALSE
# ==============================================================================
# DEV / DEPS-ONLY COMPOSE OVERRIDES
# These are only needed for docker-compose.dev.yml or docker-compose.deps-only.yml.
# Production Docker exposes Caddy only; raw app ports below do not affect
# docker-compose.yml.
# ==============================================================================
# -- pgAdmin (database GUI, dev/deps-only only) --
# PGADMIN_PORT=5050
# PGADMIN_DEFAULT_EMAIL=admin@surfsense.com
# PGADMIN_DEFAULT_PASSWORD=surfsense
# -- Redis exposed port (dev/deps-only only; Redis is internal-only in prod) --
# REDIS_PORT=6379
# -- WhatsApp bridge exposed port (dev/hybrid only; prod keeps it Docker-internal) --
# WHATSAPP_BRIDGE_PORT=9929
# -- Raw app ports (dev/deps-only only; prod exposes Caddy instead) --
# BACKEND_PORT=8000
# FRONTEND_PORT=3000
# ZERO_CACHE_PORT=4848
# -- Frontend runtime flags (prod and dev compose) --
# The frontend reads these at request time in Docker; no NEXT_PUBLIC_* rebuild
# or startup substitution is required.
# AUTH_TYPE=LOCAL
# ETL_SERVICE=DOCLING
# DEPLOYMENT_MODE=self-hosted
# Residential proxy for web crawling
# RESIDENTIAL_PROXY_USERNAME=
# RESIDENTIAL_PROXY_PASSWORD=
# RESIDENTIAL_PROXY_HOSTNAME=
# RESIDENTIAL_PROXY_LOCATION=
# RESIDENTIAL_PROXY_TYPE=1

View file

@ -1,7 +1,7 @@
# =============================================================================
# SurfSense — Dependencies only (no backend / frontend / Celery images)
# =============================================================================
# Postgres, Redis, pgAdmin, Zero — run API + Next + Celery on the host.
# Postgres, Redis, SearXNG, pgAdmin, Zero — run API + Next + Celery on the host.
# Celery is not Dockerized here: use `uv run` from surfsense_backend/ (no extra
# backend image build just for workers).
#
@ -9,7 +9,7 @@
# docker compose -f docker/docker-compose.deps-only.yml up -d
#
# Compose variable substitution uses `docker/.env` (copy from .env.example).
# Bind mounts use ./postgresql.conf in this directory.
# Bind mounts use ./postgresql.conf and ./searxng in this directory.
#
# Local Celery (from surfsense_backend/, after Redis is up):
# uv run celery -A celery_worker.celery_app worker --loglevel=info --concurrency=1 --pool=solo --queues=surfsense,surfsense.connectors
@ -17,6 +17,7 @@
#
# Host setup:
# - Backend .env: DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense
# - Backend .env: SEARXNG_DEFAULT_HOST=http://localhost:${SEARXNG_PORT:-8888}
# - Backend .env: CELERY_BROKER_URL / REDIS_APP_URL → redis://localhost:6379/0
# - Web .env: NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:${ZERO_CACHE_PORT:-4848}
#
@ -79,12 +80,26 @@ services:
timeout: 5s
retries: 5
searxng:
image: searxng/searxng:2026.3.13-3c1f68c59
ports:
- "${SEARXNG_PORT:-8888}:8080"
volumes:
- ./searxng:/etc/searxng
environment:
- SEARXNG_SECRET=${SEARXNG_SECRET:-surfsense-searxng-secret}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 5
# NOTE: zero-cache requires the `zero_publication` Postgres publication to
# exist before it starts. In this deps-only stack there is no backend
# container to run migrations, so you must run `uv run alembic upgrade head`
# from `surfsense_backend/` on the host BEFORE `docker compose up -d`.
zero-cache:
image: rocicorp/zero:1.6.0
image: rocicorp/zero:1.4.0
ports:
- "${ZERO_CACHE_PORT:-4848}:4848"
extra_hosts:
@ -105,10 +120,6 @@ services:
- ZERO_CVR_MAX_CONNS=${ZERO_CVR_MAX_CONNS:-30}
- ZERO_QUERY_URL=${ZERO_QUERY_URL:-http://host.docker.internal:3000/api/zero/query}
- ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://host.docker.internal:3000/api/zero/mutate}
- ZERO_QUERY_FORWARD_COOKIES=${ZERO_QUERY_FORWARD_COOKIES:-true}
- ZERO_QUERY_API_KEY=${ZERO_QUERY_API_KEY:-}
- ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60}
- ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60}
volumes:
- zero_cache_data:/data
restart: unless-stopped

View file

@ -85,6 +85,20 @@ services:
- "${OTEL_TEMPO_PORT:-3200}:3200"
restart: unless-stopped
searxng:
image: searxng/searxng:2026.3.13-3c1f68c59
ports:
- "${SEARXNG_PORT:-8888}:8080"
volumes:
- ./searxng:/etc/searxng
environment:
- SEARXNG_SECRET=${SEARXNG_SECRET:-surfsense-searxng-secret}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: *backend-build
ports:
@ -92,7 +106,6 @@ services:
volumes:
- ../surfsense_backend/app:/app/app
- shared_temp:/shared_tmp
- object_store:/app/.local_object_store
env_file:
- ../surfsense_backend/.env
extra_hosts:
@ -106,11 +119,11 @@ services:
- PYTHONPATH=/app
- UVICORN_LOOP=asyncio
- UNSTRUCTURED_HAS_PATCHED_LOOP=1
- FILE_STORAGE_LOCAL_PATH=/app/.local_object_store
- LANGCHAIN_TRACING_V2=false
- LANGSMITH_TRACING=false
- AUTH_TYPE=${AUTH_TYPE:-LOCAL}
- NEXT_FRONTEND_URL=${NEXT_FRONTEND_URL:-http://localhost:3000}
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
- WHATSAPP_BRIDGE_URL=${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
# Daytona Sandbox uncomment and set credentials to enable cloud code execution
# - DAYTONA_SANDBOX_ENABLED=TRUE
@ -123,6 +136,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
searxng:
condition: service_healthy
migrations:
condition: service_completed_successfully
healthcheck:
@ -156,7 +171,6 @@ services:
volumes:
- ../surfsense_backend/app:/app/app
- shared_temp:/shared_tmp
- object_store:/app/.local_object_store
env_file:
- ../surfsense_backend/.env
extra_hosts:
@ -168,7 +182,7 @@ services:
- REDIS_APP_URL=${REDIS_URL:-redis://redis:6379/0}
- CELERY_TASK_DEFAULT_QUEUE=surfsense
- PYTHONPATH=/app
- FILE_STORAGE_LOCAL_PATH=/app/.local_object_store
- SEARXNG_DEFAULT_HOST=${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
- SERVICE_ROLE=worker
depends_on:
db:
@ -202,7 +216,7 @@ services:
condition: service_started
zero-cache:
image: rocicorp/zero:1.6.0
image: rocicorp/zero:1.4.0
ports:
- "${ZERO_CACHE_PORT:-4848}:4848"
extra_hosts:
@ -225,10 +239,6 @@ services:
- ZERO_CVR_MAX_CONNS=${ZERO_CVR_MAX_CONNS:-30}
- ZERO_QUERY_URL=${ZERO_QUERY_URL:-http://frontend:3000/api/zero/query}
- ZERO_MUTATE_URL=${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
- ZERO_QUERY_FORWARD_COOKIES=${ZERO_QUERY_FORWARD_COOKIES:-true}
- ZERO_QUERY_API_KEY=${ZERO_QUERY_API_KEY:-}
- ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS=${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60}
- ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS=${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60}
volumes:
- zero_cache_data:/data
restart: unless-stopped
@ -243,15 +253,16 @@ services:
frontend:
build:
context: ../surfsense_web
args:
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:8000}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_ETL_SERVICE: ${NEXT_PUBLIC_ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_ZERO_CACHE_URL: ${NEXT_PUBLIC_ZERO_CACHE_URL:-http://localhost:${ZERO_CACHE_PORT:-4848}}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${NEXT_PUBLIC_DEPLOYMENT_MODE:-self-hosted}
ports:
- "${FRONTEND_PORT:-3000}:3000"
env_file:
- ../surfsense_web/.env
environment:
AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
SURFSENSE_BACKEND_INTERNAL_URL: http://backend:8000
depends_on:
backend:
condition: service_healthy
@ -267,8 +278,6 @@ volumes:
name: surfsense-dev-redis
shared_temp:
name: surfsense-dev-shared-temp
object_store:
name: surfsense-dev-object-store
zero_cache_data:
name: surfsense-dev-zero-cache
whatsapp_sessions:

View file

@ -1,54 +0,0 @@
# =============================================================================
# SurfSense — Optional Caddy reverse-proxy overlay
# =============================================================================
# Usage (from docker/):
# PROXY_HTTP_PORT=8080 SURFSENSE_PUBLIC_URL=http://localhost:8080 \
# docker compose -f docker-compose.yml -f docker-compose.proxy.yml up -d
#
# This overlay is for validation and custom deployments. The production
# docker-compose.yml includes Caddy by default.
# =============================================================================
services:
backend:
ports:
- "${BACKEND_PORT:-8929}:8000"
zero-cache:
ports:
- "${ZERO_CACHE_PORT:-5929}:4848"
frontend:
ports:
- "${FRONTEND_PORT:-3929}:3000"
proxy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "${PROXY_HTTP_PORT:-8080}:80"
- "${PROXY_HTTPS_PORT:-8443}:443"
volumes:
- ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
environment:
SURFSENSE_SITE_ADDRESS: ${SURFSENSE_SITE_ADDRESS:-:80}
CERT_EMAIL: ${CERT_EMAIL:-}
CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
CERT_ACME_DNS: ${CERT_ACME_DNS:-}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0}
SURFSENSE_MAX_BODY_SIZE: ${SURFSENSE_MAX_BODY_SIZE:-5GB}
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
zero-cache:
condition: service_healthy
volumes:
caddy_data:
name: surfsense-caddy-data
caddy_config:
name: surfsense-caddy-config

View file

@ -1,52 +0,0 @@
# =============================================================================
# SurfSense — Isolated deps for the watch (Phase 6) manual E2E
# =============================================================================
# Fresh Postgres + Redis ONLY, with their own project name, volumes, and host
# ports so they never collide with the day-to-day `surfsense-deps` stack.
#
# Backend + Celery run on the HOST (tests/e2e/run_backend.py / run_celery.py)
# pointed at these ports via env:
# DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5442/surfsense
# CELERY_BROKER_URL / CELERY_RESULT_BACKEND / REDIS_APP_URL=redis://localhost:6389/0
#
# Up: docker compose -p surfsense-watch -f docker/docker-compose.watch-e2e.yml up -d
# Down: docker compose -p surfsense-watch -f docker/docker-compose.watch-e2e.yml down -v
# =============================================================================
name: surfsense-watch
services:
db:
image: pgvector/pgvector:pg17
ports:
- "5442:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=surfsense
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d surfsense"]
interval: 3s
timeout: 5s
retries: 20
redis:
image: redis:8-alpine
ports:
- "6389:6379"
volumes:
- redis_data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 3s
timeout: 5s
retries: 20
volumes:
postgres_data:
name: surfsense-watch-postgres
redis_data:
name: surfsense-watch-redis

View file

@ -81,42 +81,25 @@ services:
# timeout: 5s
# retries: 3
# Single public entry point for the Docker stack. Comment this service out
# only if you front SurfSense with your own reverse proxy.
proxy:
image: caddy:2-alpine
# For DNS-01/wildcard certificates, replace image with:
# build: ./proxy
restart: unless-stopped
ports:
- "${LISTEN_HTTP_PORT:-3929}:80"
- "${LISTEN_HTTPS_PORT:-443}:443"
searxng:
image: searxng/searxng:2026.3.13-3c1f68c59
volumes:
- ./proxy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
- ./searxng:/etc/searxng
environment:
SURFSENSE_SITE_ADDRESS: ${SURFSENSE_SITE_ADDRESS:-:80}
CERT_EMAIL: ${CERT_EMAIL:-}
CERT_ACME_CA: ${CERT_ACME_CA:-https://acme-v02.api.letsencrypt.org/directory}
CERT_ACME_DNS: ${CERT_ACME_DNS:-}
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0}
SURFSENSE_MAX_BODY_SIZE: ${SURFSENSE_MAX_BODY_SIZE:-5GB}
depends_on:
frontend:
condition: service_started
backend:
condition: service_healthy
zero-cache:
condition: service_healthy
SEARXNG_SECRET: ${SEARXNG_SECRET:-surfsense-searxng-secret}
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
interval: 10s
timeout: 5s
retries: 5
backend:
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}}
expose:
- "8000"
ports:
- "${BACKEND_PORT:-8929}:8000"
volumes:
- shared_temp:/shared_tmp
- object_store:/app/.local_object_store
env_file:
- .env
extra_hosts:
@ -130,9 +113,8 @@ services:
PYTHONPATH: /app
UVICORN_LOOP: asyncio
UNSTRUCTURED_HAS_PATCHED_LOOP: "1"
FILE_STORAGE_LOCAL_PATH: /app/.local_object_store
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-${SURFSENSE_PUBLIC_URL:-http://localhost:${LISTEN_HTTP_PORT:-3929}}}
BACKEND_URL: ${BACKEND_URL:-${SURFSENSE_PUBLIC_URL:-http://localhost:${LISTEN_HTTP_PORT:-3929}}}
NEXT_FRONTEND_URL: ${NEXT_FRONTEND_URL:-http://localhost:${FRONTEND_PORT:-3929}}
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
WHATSAPP_BRIDGE_URL: ${WHATSAPP_BRIDGE_URL:-http://whatsapp-bridge:9929}
# Daytona Sandbox uncomment and set credentials to enable cloud code execution
# DAYTONA_SANDBOX_ENABLED: "TRUE"
@ -147,6 +129,8 @@ services:
condition: service_healthy
redis:
condition: service_healthy
searxng:
condition: service_healthy
migrations:
condition: service_completed_successfully
restart: unless-stopped
@ -181,7 +165,6 @@ services:
image: ghcr.io/modsetter/surfsense-backend:${SURFSENSE_VERSION:-latest}${SURFSENSE_VARIANT:+-${SURFSENSE_VARIANT}}
volumes:
- shared_temp:/shared_tmp
- object_store:/app/.local_object_store
env_file:
- .env
extra_hosts:
@ -193,7 +176,7 @@ services:
REDIS_APP_URL: ${REDIS_URL:-redis://redis:6379/0}
CELERY_TASK_DEFAULT_QUEUE: surfsense
PYTHONPATH: /app
FILE_STORAGE_LOCAL_PATH: /app/.local_object_store
SEARXNG_DEFAULT_HOST: ${SEARXNG_DEFAULT_HOST:-http://searxng:8080}
SERVICE_ROLE: worker
depends_on:
db:
@ -233,9 +216,9 @@ services:
restart: unless-stopped
zero-cache:
image: rocicorp/zero:1.6.0
expose:
- "4848"
image: rocicorp/zero:1.4.0
ports:
- "${ZERO_CACHE_PORT:-5929}:4848"
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
@ -251,10 +234,6 @@ services:
ZERO_CVR_MAX_CONNS: ${ZERO_CVR_MAX_CONNS:-30}
ZERO_QUERY_URL: ${ZERO_QUERY_URL:-http://frontend:3000/api/zero/query}
ZERO_MUTATE_URL: ${ZERO_MUTATE_URL:-http://frontend:3000/api/zero/mutate}
ZERO_QUERY_FORWARD_COOKIES: ${ZERO_QUERY_FORWARD_COOKIES:-true}
ZERO_QUERY_API_KEY: ${ZERO_QUERY_API_KEY:-}
ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS: ${ZERO_AUTH_REVALIDATE_INTERVAL_SECONDS:-60}
ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS: ${ZERO_AUTH_RETRANSFORM_INTERVAL_SECONDS:-60}
volumes:
- zero_cache_data:/data
restart: unless-stopped
@ -273,13 +252,16 @@ services:
frontend:
image: ghcr.io/modsetter/surfsense-web:${SURFSENSE_VERSION:-latest}
expose:
- "3000"
ports:
- "${FRONTEND_PORT:-3929}:3000"
environment:
AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
SURFSENSE_BACKEND_INTERNAL_URL: http://backend:8000
NEXT_PUBLIC_FASTAPI_BACKEND_URL: ${NEXT_PUBLIC_FASTAPI_BACKEND_URL:-http://localhost:${BACKEND_PORT:-8929}}
NEXT_PUBLIC_ZERO_CACHE_URL: ${NEXT_PUBLIC_ZERO_CACHE_URL:-http://localhost:${ZERO_CACHE_PORT:-5929}}
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: ${AUTH_TYPE:-LOCAL}
NEXT_PUBLIC_ETL_SERVICE: ${ETL_SERVICE:-DOCLING}
NEXT_PUBLIC_DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-self-hosted}
NEXT_PUBLIC_WHATSAPP_DISPLAY_PHONE_NUMBER: ${WHATSAPP_SHARED_DISPLAY_PHONE_NUMBER:-}
FASTAPI_BACKEND_INTERNAL_URL: ${FASTAPI_BACKEND_INTERNAL_URL:-http://backend:8000}
labels:
- "com.centurylinklabs.watchtower.enable=true"
depends_on:
@ -296,13 +278,7 @@ volumes:
name: surfsense-redis
shared_temp:
name: surfsense-shared-temp
object_store:
name: surfsense-object-store
zero_cache_data:
name: surfsense-zero-cache
caddy_data:
name: surfsense-caddy-data
caddy_config:
name: surfsense-caddy-config
whatsapp_sessions:
name: surfsense-whatsapp-sessions

View file

@ -1,49 +0,0 @@
{
# Optional ACME/global settings. These are harmless in the default :80
# localhost mode and become active when SURFSENSE_SITE_ADDRESS is a domain.
{$CERT_EMAIL}
acme_ca {$CERT_ACME_CA:https://acme-v02.api.letsencrypt.org/directory}
{$CERT_ACME_DNS}
servers {
client_ip_headers X-Forwarded-For X-Real-IP
trusted_proxies static {$TRUSTED_PROXIES:0.0.0.0/0}
}
}
(surfsense_proxy) {
request_body {
max_size {$SURFSENSE_MAX_BODY_SIZE:5GB}
}
# Frontend-owned auth page (the post-login token handler). More specific than
# /auth/*, so Caddy's matcher-specificity sort routes it here, not to backend.
reverse_proxy /auth/callback* frontend:3000
# Backend auth routes (FastAPI Users + OAuth helpers).
reverse_proxy /auth/* backend:8000
# Backend user profile routes (FastAPI Users users router, mounted at /users).
reverse_proxy /users/* backend:8000
# Backend REST, streaming, connector OAuth, and messaging gateway endpoints.
# FastAPI already serves /api/v1, so the path is forwarded unchanged.
reverse_proxy /api/v1/* backend:8000 {
flush_interval -1
}
# Zero sync auth context is a backend (FastAPI) endpoint. More specific than
# /zero/*, so Caddy's matcher-specificity sort routes it here, not to zero-cache.
reverse_proxy /zero/context backend:8000
# Zero accepts a single path-component base URL (Zero >= 0.6).
# Preserve /zero so browser cacheURL can be ${SURFSENSE_PUBLIC_URL}/zero.
reverse_proxy /zero/* zero-cache:4848
# Next.js app and frontend-owned API routes:
# /api/zero/*, /api/search, /api/contact, etc.
reverse_proxy /* frontend:3000
}
{$SURFSENSE_SITE_ADDRESS::80} {
import surfsense_proxy
}

View file

@ -1,10 +0,0 @@
FROM caddy:2-builder-alpine AS builder
RUN xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/caddy-dns/digitalocean
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile

View file

@ -329,15 +329,16 @@ Write-Step "Downloading SurfSense files"
Write-Info "Installation directory: $InstallDir"
New-Item -ItemType Directory -Path "$InstallDir\scripts" -Force | Out-Null
New-Item -ItemType Directory -Path "$InstallDir\proxy" -Force | Out-Null
New-Item -ItemType Directory -Path "$InstallDir\searxng" -Force | Out-Null
$Files = @(
@{ Src = "docker/docker-compose.yml"; Dest = "docker-compose.yml" }
@{ Src = "docker/docker-compose.gpu.yml"; Dest = "docker-compose.gpu.yml" }
@{ Src = "docker/.env.example"; Dest = ".env.example" }
@{ Src = "docker/proxy/Caddyfile"; Dest = "proxy/Caddyfile" }
@{ Src = "docker/postgresql.conf"; Dest = "postgresql.conf" }
@{ Src = "docker/scripts/migrate-database.ps1"; Dest = "scripts/migrate-database.ps1" }
@{ Src = "docker/searxng/settings.yml"; Dest = "searxng/settings.yml" }
@{ Src = "docker/searxng/limiter.toml"; Dest = "searxng/limiter.toml" }
)
foreach ($f in $Files) {

View file

@ -332,15 +332,16 @@ SELECTED_VARIANT=$(resolve_variant)
step "Downloading SurfSense files"
info "Installation directory: ${INSTALL_DIR}"
mkdir -p "${INSTALL_DIR}/scripts"
mkdir -p "${INSTALL_DIR}/proxy"
mkdir -p "${INSTALL_DIR}/searxng"
FILES=(
"docker/docker-compose.yml:docker-compose.yml"
"docker/docker-compose.gpu.yml:docker-compose.gpu.yml"
"docker/.env.example:.env.example"
"docker/proxy/Caddyfile:proxy/Caddyfile"
"docker/postgresql.conf:postgresql.conf"
"docker/scripts/migrate-database.sh:scripts/migrate-database.sh"
"docker/searxng/settings.yml:searxng/settings.yml"
"docker/searxng/limiter.toml:searxng/limiter.toml"
)
for entry in "${FILES[@]}"; do
@ -531,12 +532,9 @@ _variant_display=$(grep '^SURFSENSE_VARIANT=' "${INSTALL_DIR}/.env" 2>/dev/null
_variant_display="${_variant_display:-cpu}"
step "SurfSense is now installed [${_version_display}]"
_public_url=$(grep '^SURFSENSE_PUBLIC_URL=' "${INSTALL_DIR}/.env" 2>/dev/null | cut -d= -f2- | tr -d '"' | head -1 || true)
_public_url="${_public_url:-http://localhost:3929}"
info " SurfSense: ${_public_url}"
info " Backend: ${_public_url}/api/v1"
info " Zero sync: ${_public_url}/zero"
info " Frontend: http://localhost:3929"
info " Backend: http://localhost:8929"
info " API Docs: http://localhost:8929/docs"
info ""
info " Config: ${INSTALL_DIR}/.env"
info " Variant: ${_variant_display}"

View file

@ -0,0 +1,5 @@
[botdetection.ip_limit]
link_token = false
[botdetection.ip_lists]
pass_ip = ["0.0.0.0/0"]

View file

@ -0,0 +1,90 @@
use_default_settings:
engines:
remove:
- ahmia
- torch
- qwant
- qwant news
- qwant images
- qwant videos
- mojeek
- mojeek images
- mojeek news
server:
secret_key: "override-me-via-env"
limiter: false
image_proxy: false
method: "GET"
default_http_headers:
X-Robots-Tag: "noindex, nofollow"
search:
formats:
- html
- json
default_lang: "auto"
autocomplete: ""
safe_search: 0
ban_time_on_fail: 5
max_ban_time_on_fail: 120
suspended_times:
SearxEngineAccessDenied: 3600
SearxEngineCaptcha: 3600
SearxEngineTooManyRequests: 600
cf_SearxEngineCaptcha: 7200
cf_SearxEngineAccessDenied: 3600
recaptcha_SearxEngineCaptcha: 7200
ui:
static_use_hash: true
outgoing:
request_timeout: 12.0
max_request_timeout: 20.0
pool_connections: 100
pool_maxsize: 20
enable_http2: true
extra_proxy_timeout: 10
retries: 1
# Uncomment and set your residential proxy URL to route search engine requests through it.
# Format: http://<username>:<base64_password>@<hostname>:<port>/
#
# proxies:
# all://:
# - http://user:pass@proxy-host:port/
engines:
- name: google
disabled: false
weight: 1.2
retry_on_http_error: [429, 503]
- name: duckduckgo
disabled: false
weight: 1.1
retry_on_http_error: [429, 503]
- name: brave
disabled: false
weight: 1.0
retry_on_http_error: [429, 503]
- name: bing
disabled: false
weight: 0.9
retry_on_http_error: [429, 503]
- name: wikipedia
disabled: false
weight: 0.8
- name: stackoverflow
disabled: false
weight: 0.7
- name: yahoo
disabled: false
weight: 0.7
retry_on_http_error: [429, 503]
- name: wikidata
disabled: false
weight: 0.6
- name: currency
disabled: false
- name: ddg definitions
disabled: false

View file

@ -6,12 +6,6 @@
"sourceType": "github",
"computedHash": "f96aa6f7d8dd982a866ac090c1770e39cd0a01c63c9bc315895d3c210f80b393"
},
"animation-vocabulary": {
"source": "emilkowalski/skills",
"sourceType": "github",
"skillPath": "skills/animation-vocabulary/SKILL.md",
"computedHash": "280c6688ef2568ad92f40c4dc638f0cc5cde0f49bdd6debd097410aa9945f44f"
},
"backlink-analyzer": {
"source": "aaron-he-zhu/seo-geo-claude-skills",
"sourceType": "github",
@ -42,12 +36,6 @@
"sourceType": "github",
"computedHash": "e97547ac36ea8e59a65bdfdda25b8d130cb7f5e2cf13dac56fd01f24e74718a5"
},
"emil-design-eng": {
"source": "emilkowalski/skills",
"sourceType": "github",
"skillPath": "skills/emil-design-eng/SKILL.md",
"computedHash": "ac3fc5b4e206488ffc408a2806f829bb2e5eaf518fded09be34790685c2b3a0d"
},
"entity-optimizer": {
"source": "aaron-he-zhu/seo-geo-claude-skills",
"sourceType": "github",
@ -99,12 +87,6 @@
"sourceType": "github",
"computedHash": "2d4c9a44056713d946481031cc00b3113b6055af6f4bea0cfd8dcb8a9d5abbf9"
},
"review-animations": {
"source": "emilkowalski/skills",
"sourceType": "github",
"skillPath": "skills/review-animations/SKILL.md",
"computedHash": "57c681344448379fbec8c11b45e355b70799998427240662318d017f292571bd"
},
"schema-markup-generator": {
"source": "aaron-he-zhu/seo-geo-claude-skills",
"sourceType": "github",

View file

@ -1,20 +1,5 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense
# --- Database startup / safety knobs (optional) ---
# Run extension/table/index DDL on app startup. Set FALSE when schema is owned
# exclusively by Alembic migrations.
# DB_BOOTSTRAP_ON_STARTUP=TRUE
# lock_timeout (ms) for boot-time DDL so a contended CREATE INDEX/TABLE fails
# fast instead of hanging the FastAPI lifespan behind another transaction.
# DB_DDL_LOCK_TIMEOUT_MS=5000
# idle_in_transaction_session_timeout (ms) so an abandoned "idle in transaction"
# session can't wedge the DB indefinitely. 0 disables. (asyncpg only)
# DB_IDLE_IN_TX_TIMEOUT_MS=900000
# Same, for the Celery worker engine (long ingestion/podcast/video tasks). If a
# task hasn't touched the DB in this window it's treated as orphaned and dropped.
# 0 disables. (asyncpg only)
# DB_CELERY_IDLE_IN_TX_TIMEOUT_MS=3600000
# Deployment environment: dev or production
SURFSENSE_ENV=dev
@ -30,9 +15,12 @@ CELERY_TASK_DEFAULT_QUEUE=surfsense
# Optional: TTL in seconds for connector indexing lock key
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
# Messaging Gateway: disabled by default; set TRUE to enable chat integrations.
# Supported messaging gateways: WhatsApp, Telegram, Discord, Slack
# GATEWAY_ENABLED=TRUE
# Messaging Gateway (global)
# GATEWAY_ENABLED: master switch for ALL messaging gateway channels (Telegram, WhatsApp,
# Slack, Discord). When FALSE, no gateway background workers/supervisors start and all
# gateway HTTP routes (webhooks, OAuth callbacks, pairing) return 404. Set per-channel
# flags below to control individual platforms once the gateway is enabled.
GATEWAY_ENABLED=TRUE
# Telegram Gateway
# TELEGRAM_WEBHOOK_SECRET must be 1-256 chars and contain only A-Z, a-z, 0-9, _ or -
@ -55,6 +43,11 @@ WHATSAPP_WEBHOOK_VERIFY_TOKEN=
WHATSAPP_WEBHOOK_APP_SECRET=
WHATSAPP_BRIDGE_URL=http://whatsapp-bridge:9929
# Platform Web Search (SearXNG)
# Set this to enable built-in web search. Docker Compose sets it automatically.
# Only uncomment if running the backend outside Docker (e.g. uvicorn on host).
# SEARXNG_DEFAULT_HOST=http://localhost:8888
# Periodic task interval
# # Run every minute (default)
# SCHEDULE_CHECKER_INTERVAL=1m
@ -76,27 +69,9 @@ STRIPE_RECONCILIATION_INTERVAL=10m
SECRET_KEY=SECRET
# JWT/session lifetimes (optional, defaults shown)
# ACCESS_TOKEN_LIFETIME_SECONDS=1800 # 30 minutes
# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 14-day inactivity window
# REFRESH_ROTATION_GRACE_SECONDS=45
# REFRESH_ABSOLUTE_LIFETIME_SECONDS=2592000 # 30-day absolute cap
#
# Web session cookies. Leave COOKIE_DOMAIN empty for self-hosted same-origin
# Docker. In cloud, use .surfsense.com so api., zero., and the app share the
# first-party session cookie.
# SESSION_COOKIE_NAME=surfsense_session
# REFRESH_COOKIE_NAME=surfsense_refresh
# SESSION_COOKIE_SECURE_POLICY=auto
# SESSION_COOKIE_SAMESITE=lax
# COOKIE_DOMAIN=
#
# Comma-separated allow-list for cookie-session unsafe requests. Defaults also
# include NEXT_FRONTEND_URL and SURFSENSE_PUBLIC_URL when set.
# CSRF_ALLOWED_ORIGINS=http://localhost:3000
# Personal Access Tokens (PATs). Empty/unset = no maximum; users may create
# never-expiring PATs. When set, PAT creation requires an expiry <= this many days.
# PAT_MAX_EXPIRY_DAYS=
# JWT Token Lifetimes (optional, defaults shown)
# ACCESS_TOKEN_LIFETIME_SECONDS=86400 # 1 day
# REFRESH_TOKEN_LIFETIME_SECONDS=1209600 # 2 weeks
NEXT_FRONTEND_URL=http://localhost:3000
@ -125,8 +100,6 @@ REGISTRATION_ENABLED=TRUE or FALSE
# For Google Auth Only
GOOGLE_OAUTH_CLIENT_ID=924507538m
GOOGLE_OAUTH_CLIENT_SECRET=GOCSV
GOOGLE_DESKTOP_CLIENT_ID=your_google_desktop_client_id
GOOGLE_DESKTOP_CLIENT_SECRET=your_google_desktop_client_secret
GOOGLE_PICKER_API_KEY=your-google-picker-api-key
# Google Connector Specific Configurations
@ -252,41 +225,6 @@ DEFAULT_CREDIT_MICROS_BALANCE=5000000
ETL_CREDIT_BILLING_ENABLED=FALSE
MICROS_PER_PAGE=1000
# Debit the credit wallet per *successful* web crawl. Default FALSE keeps
# crawling effectively free for self-hosted/OSS installs; hosted sets TRUE.
# Price is fully config-driven (the only source of truth, no hardcoded rate):
# WEB_CRAWL_MICROS_PER_SUCCESS = round(USD_per_1000_crawls * 1_000)
# 2000 == $2 / 1000 crawls (default) | 1000 == $1/1000 | 500 == $0.50/1000
# Retune anytime with just an env change + restart (no code/migration).
# WEB_CRAWL_CREDIT_BILLING_ENABLED=FALSE
# WEB_CRAWL_MICROS_PER_SUCCESS=2000
# Phase 3d: bill captcha solves as a SEPARATE per-attempt unit (the solver
# charges per attempt regardless of crawl success). Independent of the crawl
# flag above. Price config-driven (no hardcoded rate):
# WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE = round(USD_per_1000_solves * 1_000)
# 3000 == $3 / 1000 solves (default) | 5000 == $5/1000
# Set with margin over your solver vendor's per-attempt price.
# WEB_CRAWL_CAPTCHA_BILLING_ENABLED=FALSE
# WEB_CRAWL_CAPTCHA_MICROS_PER_SOLVE=3000
# Debit the credit wallet per *item returned* by the platform-native scrapers
# (Reddit, Google Search, Google Maps, YouTube). Default FALSE keeps scraping
# effectively free for self-hosted/OSS installs; hosted deployments set TRUE.
# Each rate is micro-USD per item, fully config-driven (no hardcoded rate):
# <KEY> = round(USD_per_1000_items * 1_000)
# 3500 == $3.50/1000 | 5000 == $5/1000 | 2000 == $2/1000
# Defaults sit at/above Apify's first-party actor rates (Jul 2026); justified
# because we charge no subscription tiers, no per-run start fees, and no
# separate proxy/compute/storage billing. Retune anytime via env + restart.
# PLATFORM_SCRAPE_BILLING_ENABLED=FALSE
# REDDIT_SCRAPE_MICROS_PER_ITEM=3500
# GOOGLE_SEARCH_MICROS_PER_SERP=5500
# GOOGLE_MAPS_MICROS_PER_PLACE=3500
# GOOGLE_MAPS_MICROS_PER_REVIEW=1500
# YOUTUBE_MICROS_PER_VIDEO=2500
# YOUTUBE_MICROS_PER_COMMENT=1500
# Low-balance warning threshold (micro-USD), surfaced to the UI. Default $0.50.
CREDIT_LOW_BALANCE_WARNING_MICROS=500000
@ -343,57 +281,17 @@ TURNSTILE_SECRET_KEY=
# Proxy provider selection. Selects a ProxyProvider implementation registered in
# app/utils/proxy/registry.py. Default: "custom". Options: "custom", "dataimpulse".
# PROXY_PROVIDER=custom
# app/utils/proxy/registry.py. Default: "anonymous_proxies". Add new vendors there.
# PROXY_PROVIDER=anonymous_proxies
# Proxy endpoint(s), shared across providers — PROXY_PROVIDER picks the behavior,
# not a different env name. Used for web crawling, link previews, and YouTube
# transcript fetching to avoid IP bans. Leave unset to disable proxying.
#
# PROXY_URL: a single full http://user:pass@host:port endpoint (used by every
# provider). For "dataimpulse", country is a "__cr.<country>" username suffix the
# provider parses for geoip-match, e.g.:
# PROXY_URL=http://your_token__cr.us:your_password@gw.dataimpulse.com:823
# PROXY_URLS: a comma-separated pool the "custom" provider rotates client-side
# (cyclic); server-side-rotating gateways ignore it.
# PROXY_URL=http://user:pass@host:port
# PROXY_URLS=http://user:pass@host1:port,http://user:pass@host2:port
# =====================================================================
# Captcha solving (Phase 3d) — LAST-resort bypass tier via captchatools.
# Only fires on the stealth browser tier when a sitekey is detected AND
# CAPTCHA_SOLVING_ENABLED=TRUE. Cloudflare Turnstile is already solved free
# in-framework (no config needed). Off by default => zero attempts, zero cost.
# NOTE: automated solving may violate a target site's ToS — opt-in, public
# data only (no logged-in bypass). captchatools is itself the vendor registry.
# CAPTCHA_SOLVING_ENABLED=FALSE
# solving_site: capmonster | 2captcha | anticaptcha | capsolver | captchaai
# CAPTCHA_SOLVER_PROVIDER=capsolver
# CAPTCHA_SOLVER_API_KEY=
# Per-URL solve cap (bounds solver spend on a hostile page).
# CAPTCHA_MAX_ATTEMPTS_PER_URL=1
# CAPTCHA_SOLVE_TIMEOUT_S=120
# Default type when detection is ambiguous: v2 | v3 | hcaptcha
# CAPTCHA_TYPE_DEFAULT=v2
# reCAPTCHA v3 tuning (only used for v3 challenges).
# CAPTCHA_V3_MIN_SCORE=0.7
# CAPTCHA_V3_ACTION=verify
# =====================================================================
# Stealth hardening (Phase 3e, Slice A) — runtime/config-level levers on the
# stealth browser tier. Consumed by app/proprietary/web_crawler/stealth.py.
# Defaults add no crawl-speed regression and preserve today's behavior.
# Match browser locale/timezone to the proxy provider's exit region (no exit-IP lookup).
# CRAWL_GEOIP_MATCH_ENABLED=FALSE
# WebRTC respects the proxy (anti local-IP leak). Cheap + safe.
# CRAWL_BLOCK_WEBRTC=TRUE
# Random canvas noise (an unstable canvas hash is itself a tell) — opt-in only.
# CRAWL_HIDE_CANVAS=FALSE
# Send a Google referer so the first hit looks like organic arrival.
# CRAWL_GOOGLE_SEARCH_REFERER=TRUE
# Route DNS via Cloudflare DoH (anti DNS-leak). Adds a DNS round-trip => off by
# default to avoid any speed regression; enable for leak-safety-first setups.
# CRAWL_DNS_OVER_HTTPS=FALSE
# Residential Proxy Configuration (anonymous-proxies.net)
# Used for web crawling, link previews, and YouTube transcript fetching to avoid IP bans.
# Consumed by the "anonymous_proxies" provider. Leave commented out to disable proxying.
# RESIDENTIAL_PROXY_USERNAME=your_proxy_username
# RESIDENTIAL_PROXY_PASSWORD=your_proxy_password
# RESIDENTIAL_PROXY_HOSTNAME=rotating.dnsproxifier.com:31230
# RESIDENTIAL_PROXY_LOCATION=
# RESIDENTIAL_PROXY_TYPE=1
# File Parser Service
ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
@ -413,42 +311,6 @@ FILE_STORAGE_BACKEND=local
# AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...;EndpointSuffix=core.windows.net
# AZURE_STORAGE_CONTAINER=surfsense-documents
# ETL Parse Cache
# Reuse parser output for identical file bytes across workspaces (skips paid
# re-parsing on LlamaCloud / Azure DI / Unstructured). Off by default.
ETL_CACHE_ENABLED=false
# Bump to invalidate all cached entries after a parser/behaviour change.
# ETL_CACHE_PARSER_VERSION=1
# Prune entries unused for this many days.
# ETL_CACHE_TTL_DAYS=90
# Soft cap on total cached markdown; coldest entries are evicted past it.
# ETL_CACHE_MAX_TOTAL_MB=5120
# Rows deleted per eviction pass.
# ETL_CACHE_EVICTION_BATCH=500
# Optional dedicated blob storage; unset reuses the main file storage backend.
# ETL_CACHE_STORAGE_BACKEND=azure
# ETL_CACHE_STORAGE_CONTAINER=surfsense-etl-cache
# ETL_CACHE_STORAGE_LOCAL_PATH=/var/lib/surfsense/etl-cache
# Embedding Cache
# Reuse chunk+embedding output for identical markdown across workspaces (skips
# re-chunking and re-embedding). Blobs share the ETL_CACHE_STORAGE_* backend.
# Off by default.
EMBEDDING_CACHE_ENABLED=false
# Bump to invalidate all cached embedding sets after a chunker change.
# EMBEDDING_CACHE_CHUNKER_VERSION=1
# Prune entries unused for this many days.
# EMBEDDING_CACHE_TTL_DAYS=90
# Soft cap on total cached embeddings; coldest entries are evicted past it.
# EMBEDDING_CACHE_MAX_TOTAL_MB=5120
# Rows deleted per eviction pass.
# EMBEDDING_CACHE_EVICTION_BATCH=500
# Incremental re-indexing: on document edits, keep chunks whose text is
# unchanged (reusing their embeddings) and embed only new/changed ones.
# Set to false to fall back to delete-all + full re-embed (kill switch).
# CHUNK_RECONCILE_ENABLED=true
# Daytona Sandbox (isolated code execution)
# DAYTONA_SANDBOX_ENABLED=FALSE
# DAYTONA_API_KEY=your-daytona-api-key
@ -488,9 +350,7 @@ LANGSMITH_PROJECT=surfsense
# SURFSENSE_ENABLE_LLM_TOOL_SELECTOR=false # adds a per-turn LLM call
# Observability - OTel
# Disabled by default. Uncomment to enable OpenTelemetry.
# SURFSENSE_ENABLE_OTEL=true
# SURFSENSE_ENABLE_OTEL=false
# OpenTelemetry - endpoint enables export; absent = no-op.
# Production should point at an OTel Collector. For local docker-compose.dev.yml,
# use http://otel-lgtm:4317 instead.
@ -503,6 +363,14 @@ LANGSMITH_PROJECT=surfsense
# Skills + subagents
# SURFSENSE_ENABLE_SKILLS=false
# SURFSENSE_ENABLE_SPECIALIZED_SUBAGENTS=false
# SURFSENSE_ENABLE_KB_PLANNER_RUNNABLE=false
# KB retrieval mode (default OFF = lazy). When OFF, the main agent retrieves
# KB content on demand via the `search_knowledge_base` tool and skips the
# expensive per-turn pre-injection (planner LLM + embed + hybrid search,
# ~2.3s); explicit @-mentions are still surfaced cheaply. Set to true to
# restore the original eager `<priority_documents>` pre-injection.
# SURFSENSE_ENABLE_KB_PRIORITY_PREINJECTION=false
# Snapshot / revert
# SURFSENSE_ENABLE_ACTION_LOG=false
@ -542,7 +410,7 @@ LANGSMITH_PROJECT=surfsense
# -----------------------------------------------------------------------------
# Connector discovery TTL cache (Phase 1.4 perf optimization)
# -----------------------------------------------------------------------------
# Caches the per-workspace "available connectors" + "available document
# Caches the per-search-space "available connectors" + "available document
# types" lookups that ``create_surfsense_deep_agent`` hits on every turn.
# ORM event listeners auto-invalidate on connector / document inserts,
# updates and deletes — the TTL only bounds staleness for bulk-import
@ -582,8 +450,8 @@ LANGSMITH_PROJECT=surfsense
# Per-workspace spawn-paused kill switch — set via Redis at runtime, not
# this env var. The env var below only disables the check itself (useful
# for local dev without Redis). To pause a workspace in production:
# redis-cli SET surfsense:spawn_paused:<workspace_id> 1 EX 600
# redis-cli DEL surfsense:spawn_paused:<workspace_id>
# redis-cli SET surfsense:spawn_paused:<search_space_id> 1 EX 600
# redis-cli DEL surfsense:spawn_paused:<search_space_id>
# The check is fail-open: a Redis blip never blocks ``task(...)``.
# SURFSENSE_TASK_SPAWN_PAUSED_DISABLED=false

View file

@ -30,20 +30,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxrender1 \
dos2unix \
git \
# ── Phase 3e stealth hardening ──────────────────────────────────────────
# Xvfb: virtual framebuffer so the stealth browser can run headful
# (headless=False) without a real display — many WAFs flag headless Chromium.
# Gated at runtime by CRAWL_HEADED_XVFB_ENABLED (Slice B); installed now so
# the image is ready. Real font packages make canvas/emoji/font-enumeration
# fingerprints resemble a real desktop (set proven against Kasada/Akamai).
xvfb \
fonts-noto-color-emoji \
fonts-unifont \
fonts-ipafont-gothic \
fonts-wqy-zenhei \
fonts-tlwg-loma-otf \
fonts-dejavu \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
RUN which ffmpeg && ffmpeg -version
@ -123,7 +109,7 @@ RUN --mount=type=secret,id=HF_TOKEN \
HF_TOKEN="$(cat /run/secrets/HF_TOKEN 2>/dev/null || true)" \
python -c "from chonkie import AutoEmbeddings; AutoEmbeddings.get_embeddings('${EMBEDDING_MODEL}')"
# Install Scrapling's browser engines (patchright Chromium).
# Install Scrapling's browser engines (patchright Chromium + Camoufox).
# Scrapling pulls playwright/patchright via the `fetchers` extra; `scrapling install`
# downloads the matching browser binaries used by DynamicFetcher/StealthyFetcher.
RUN scrapling install

View file

@ -1,11 +1,9 @@
import asyncio
import logging
import os
import sys
from logging.config import fileConfig
import sqlalchemy as sa
from alembic.script import ScriptDirectory
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
@ -42,153 +40,6 @@ target_metadata = Base.metadata
MIGRATION_ADVISORY_LOCK_NAMESPACE = "surfsense"
MIGRATION_ADVISORY_LOCK_NAME = "alembic_migrations"
# Migration 170 renamed searchspaces -> workspaces, so a ``workspaces`` table
# can only exist once the schema is at revision >= 170. If it exists while the
# recorded revision is missing or still pre-170, the schema did not come from
# this migration history at all -- it was created by the startup bootstrap
# (``Base.metadata.create_all`` in ``app.db.create_db_and_tables``), which
# always builds the *current* model shape. Replaying history against such a
# schema fails (e.g. migration 5's ``ALTER COLUMN ... TYPE`` is rejected
# because the column already sits in zero_publication's column list), so the
# schema is adopted by stamping head instead.
BOOTSTRAP_MARKER_TABLE = "workspaces"
RENAME_REVISION = "170"
def _stamp_head(connection: Connection, script: ScriptDirectory) -> None:
context.get_context().stamp(script, script.get_current_head())
if connection.in_transaction():
# The outer begin_transaction() is a no-op under
# transaction_per_migration, so commit explicitly.
connection.commit()
def _fast_forward_fresh_db(connection: Connection) -> bool:
"""Build a fresh (empty) DB at head via create_all instead of replaying.
Historical migrations were written against the pre-workspace-rename
schema (``searchspaces``, ``search_space_id``), while migration 0's
``create_all`` builds the *current* models -- so replaying the chain on a
fresh DB crashes as soon as a migration touches a renamed object (first
at migration 18). A fresh DB needs no history: create the head-shape
schema directly, mirror migration 0's indexes, create the Zero
publication, and stamp head. Replay remains only for legacy DBs that
genuinely contain the old objects.
ponytail: seed-data migrations (114/128 default prompts) are skipped on
this path, same as always for create_all-bootstrapped DBs; the app copes
with missing seeds. If seeds ever become mandatory, add a runtime seeding
step rather than resurrecting the replay.
"""
for table in ("documents", "searchspaces", BOOTSTRAP_MARKER_TABLE):
if connection.execute(sa.text("SELECT to_regclass(:t)"), {"t": table}).scalar():
return False
if connection.execute(sa.text("SELECT to_regclass('alembic_version')")).scalar():
current = connection.execute(
sa.text("SELECT version_num FROM alembic_version")
).scalar()
if current:
return False
logging.getLogger("alembic.env").info(
"Fresh database detected: creating head-shape schema via create_all "
"and stamping head instead of replaying migration history."
)
connection.execute(sa.text("CREATE EXTENSION IF NOT EXISTS vector"))
connection.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
Base.metadata.create_all(bind=connection)
# Same core indexes migration 0 created (runtime setup_indexes() adds the
# rest concurrently on app boot).
connection.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS document_vector_index ON documents "
"USING hnsw (embedding public.vector_cosine_ops)"
)
)
connection.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS document_search_index ON documents "
"USING gin (to_tsvector('english', content))"
)
)
connection.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS chucks_vector_index ON chunks "
"USING hnsw (embedding public.vector_cosine_ops)"
)
)
connection.execute(
sa.text(
"CREATE INDEX IF NOT EXISTS chucks_search_index ON chunks "
"USING gin (to_tsvector('english', content))"
)
)
from app.zero_publication import ensure_publication
ensure_publication(connection)
_stamp_head(connection, ScriptDirectory.from_config(config))
return True
def _adopt_bootstrapped_schema(connection: Connection) -> bool:
"""Stamp head instead of replaying history on a create_all-created DB.
Returns True when the schema was adopted (migrations must then be
skipped for this run).
ponytail: assumes the bootstrapped schema matches the checked-out models
(true whenever the backend booted on this checkout, since create_all runs
on every startup). If the checkout moved ahead without a backend boot,
column-level drift from the skipped migrations is possible; the upgrade
path is re-bootstrapping (boot the backend once) before stamping.
"""
marker = connection.execute(
sa.text("SELECT to_regclass(:t)"), {"t": BOOTSTRAP_MARKER_TABLE}
).scalar()
if marker is None:
return False
# Guard against a legacy-shape DB that merely had missing tables filled in
# by a later create_all: adoption requires the core tables to be in the
# current (post-rename) shape too, not just the marker table to exist.
documents_renamed = connection.execute(
sa.text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_schema = current_schema() "
"AND table_name = 'documents' AND column_name = 'workspace_id'"
)
).scalar()
if not documents_renamed:
return False
current = None
if connection.execute(sa.text("SELECT to_regclass('alembic_version')")).scalar():
current = connection.execute(
sa.text("SELECT version_num FROM alembic_version")
).scalar()
script = ScriptDirectory.from_config(config)
pre_rename_revisions = {
rev.revision for rev in script.iterate_revisions(RENAME_REVISION, "base")
} - {RENAME_REVISION}
if current is not None and current not in pre_rename_revisions:
# Genuinely migration-managed at >= 170; run migrations normally.
return False
logging.getLogger("alembic.env").info(
"Adopting bootstrap-created schema (%r exists, recorded revision %r "
"predates the workspace rename): stamping %s instead of replaying "
"migration history.",
BOOTSTRAP_MARKER_TABLE,
current,
script.get_current_head(),
)
_stamp_head(connection, script)
return True
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
@ -235,9 +86,6 @@ def do_run_migrations(connection: Connection) -> None:
lock_params,
)
try:
if not _fast_forward_fresh_db(connection) and not _adopt_bootstrapped_schema(
connection
):
with context.begin_transaction():
context.run_migrations()
finally:

View file

@ -4,7 +4,7 @@ Revision ID: 138
Revises: 137
Create Date: 2026-04-30
Add a single thread-level column to persist the Auto model pin:
Add a single thread-level column to persist the Auto (Fastest) model pin:
- pinned_llm_config_id: concrete resolved global LLM config id used for this
thread. NULL means "no pin; Auto will resolve on next turn".

View file

@ -15,19 +15,6 @@ down_revision: str | None = "157"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
PUBLICATION_NAME = "zero_publication"
TARGET_STATUS_LABELS = (
"pending",
"awaiting_brief",
"drafting",
"awaiting_review",
"rendering",
"ready",
"failed",
"cancelled",
)
LEGACY_STATUS_LABELS = ("pending", "generating", "ready", "failed")
def _drop_podcasts_from_publication() -> None:
"""Detach podcasts from zero_publication so status can be retyped.
@ -41,103 +28,31 @@ def _drop_podcasts_from_publication() -> None:
published = conn.execute(
sa.text(
"SELECT 1 FROM pg_publication_tables "
"WHERE pubname = :publication "
"WHERE pubname = 'zero_publication' "
"AND schemaname = current_schema() AND tablename = 'podcasts'"
),
{"publication": PUBLICATION_NAME},
)
).fetchone()
if published:
op.execute(f'ALTER PUBLICATION "{PUBLICATION_NAME}" DROP TABLE "podcasts";')
op.execute('ALTER PUBLICATION "zero_publication" DROP TABLE "podcasts";')
def _enum_labels(type_name: str) -> list[str] | None:
rows = (
op.get_bind()
.execute(
sa.text(
"SELECT e.enumlabel "
"FROM pg_type t "
"JOIN pg_namespace n ON n.oid = t.typnamespace "
"JOIN pg_enum e ON e.enumtypid = t.oid "
"WHERE n.nspname = current_schema() AND t.typname = :type_name "
"ORDER BY e.enumsortorder"
),
{"type_name": type_name},
)
.fetchall()
)
if not rows:
return None
return [str(row[0]) for row in rows]
def upgrade() -> None:
_drop_podcasts_from_publication()
def _column_type_name(table: str, column: str) -> str | None:
row = (
op.get_bind()
.execute(
sa.text(
"SELECT udt_name "
"FROM information_schema.columns "
"WHERE table_schema = current_schema() "
"AND table_name = :table AND column_name = :column"
),
{"table": table, "column": column},
)
.fetchone()
)
return str(row[0]) if row else None
def _ensure_status_enum(
*,
desired_labels: tuple[str, ...],
temporary_type: str,
create_sql: str,
alter_sql: str,
default_value: str,
) -> None:
current_labels = _enum_labels("podcast_status")
desired = list(desired_labels)
if current_labels != desired:
if current_labels is None:
if _enum_labels(temporary_type) is None:
raise RuntimeError("podcast_status enum is missing")
elif _enum_labels(temporary_type) is None:
op.execute(f"ALTER TYPE podcast_status RENAME TO {temporary_type};")
else:
raise RuntimeError(
"podcast_status and its temporary replacement both exist"
)
if _enum_labels("podcast_status") is None:
op.execute(create_sql)
if _enum_labels("podcast_status") != desired:
raise RuntimeError("podcast_status enum is not in the expected shape")
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
if _column_type_name("podcasts", "status") != "podcast_status":
op.execute(alter_sql)
# Retype the status enum by swapping in a fresh type and casting existing
# rows. The legacy transient value 'generating' maps onto 'rendering'.
op.execute("ALTER TYPE podcast_status RENAME TO podcast_status_old;")
op.execute(
f"ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT '{default_value}';"
)
if _enum_labels(temporary_type) is not None:
op.execute(f"DROP TYPE {temporary_type};")
def _upgrade_status_enum() -> None:
_ensure_status_enum(
desired_labels=TARGET_STATUS_LABELS,
temporary_type="podcast_status_old",
create_sql="""
"""
CREATE TYPE podcast_status AS ENUM (
'pending', 'awaiting_brief', 'drafting', 'awaiting_review',
'rendering', 'ready', 'failed', 'cancelled'
);
""",
alter_sql="""
"""
)
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
op.execute(
"""
ALTER TABLE podcasts
ALTER COLUMN status TYPE podcast_status
USING (
@ -146,43 +61,10 @@ def _upgrade_status_enum() -> None:
ELSE status::text
END
)::podcast_status;
""",
default_value="pending",
"""
)
def _downgrade_status_enum() -> None:
_ensure_status_enum(
desired_labels=LEGACY_STATUS_LABELS,
temporary_type="podcast_status_new",
create_sql=(
"CREATE TYPE podcast_status AS ENUM "
"('pending', 'generating', 'ready', 'failed');"
),
alter_sql="""
ALTER TABLE podcasts
ALTER COLUMN status TYPE podcast_status
USING (
CASE status::text
WHEN 'awaiting_brief' THEN 'pending'
WHEN 'drafting' THEN 'generating'
WHEN 'awaiting_review' THEN 'generating'
WHEN 'rendering' THEN 'generating'
WHEN 'cancelled' THEN 'failed'
ELSE status::text
END
)::podcast_status;
""",
default_value="ready",
)
def upgrade() -> None:
_drop_podcasts_from_publication()
# Retype the status enum by swapping in a fresh type and casting existing
# rows. The legacy transient value 'generating' maps onto 'rendering'.
_upgrade_status_enum()
op.execute("ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT 'pending';")
op.execute("DROP TYPE podcast_status_old;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS source_content TEXT;")
op.execute("ALTER TABLE podcasts ADD COLUMN IF NOT EXISTS spec JSONB;")
@ -201,8 +83,6 @@ def upgrade() -> None:
def downgrade() -> None:
_drop_podcasts_from_publication()
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS error;")
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS duration_seconds;")
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS storage_key;")
@ -212,4 +92,27 @@ def downgrade() -> None:
op.execute("ALTER TABLE podcasts DROP COLUMN IF EXISTS source_content;")
# Collapse the expanded lifecycle back onto the original four values.
_downgrade_status_enum()
op.execute("ALTER TYPE podcast_status RENAME TO podcast_status_new;")
op.execute(
"CREATE TYPE podcast_status AS ENUM "
"('pending', 'generating', 'ready', 'failed');"
)
op.execute("ALTER TABLE podcasts ALTER COLUMN status DROP DEFAULT;")
op.execute(
"""
ALTER TABLE podcasts
ALTER COLUMN status TYPE podcast_status
USING (
CASE status::text
WHEN 'awaiting_brief' THEN 'pending'
WHEN 'drafting' THEN 'generating'
WHEN 'awaiting_review' THEN 'generating'
WHEN 'rendering' THEN 'generating'
WHEN 'cancelled' THEN 'failed'
ELSE status::text
END
)::podcast_status;
"""
)
op.execute("ALTER TABLE podcasts ALTER COLUMN status SET DEFAULT 'ready';")
op.execute("DROP TYPE podcast_status_new;")

View file

@ -1,299 +0,0 @@
"""add model connections
Revision ID: 160
Revises: 159
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
revision: str = "160"
down_revision: str | None = "159"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
connection_scope = postgresql.ENUM(
"GLOBAL",
"SEARCH_SPACE",
"USER",
name="connectionscope",
create_type=False,
)
model_source = postgresql.ENUM(
"DISCOVERED",
"MANUAL",
name="modelsource",
create_type=False,
)
def _table_exists(table_name: str) -> bool:
return table_name in sa.inspect(op.get_bind()).get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _index_exists(table_name: str, index_name: str) -> bool:
if not _table_exists(table_name):
return False
return index_name in {
index["name"] for index in sa.inspect(op.get_bind()).get_indexes(table_name)
}
def _create_index_if_missing(
index_name: str,
table_name: str,
columns: list[str],
) -> None:
if not _index_exists(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=False)
def _add_searchspace_column_if_missing(
column_name: str,
*,
server_default: object | None = None,
) -> None:
if not _column_exists("searchspaces", column_name):
op.add_column(
"searchspaces",
sa.Column(
column_name,
sa.Integer(),
nullable=True,
server_default=server_default,
),
)
def _drop_column_if_exists(table_name: str, column_name: str) -> None:
if _column_exists(table_name, column_name):
op.drop_column(table_name, column_name)
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
if _index_exists(table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
connection_scope.create(bind, checkfirst=True)
model_source.create(bind, checkfirst=True)
if _table_exists("connections"):
if _column_exists("connections", "litellm_provider") and not _column_exists(
"connections", "provider"
):
op.alter_column(
"connections",
"litellm_provider",
new_column_name="provider",
existing_type=sa.String(length=100),
existing_nullable=True,
)
op.alter_column(
"connections",
"provider",
existing_type=sa.String(length=100),
nullable=False,
)
elif _column_exists("connections", "native_provider") and not _column_exists(
"connections", "provider"
):
op.alter_column(
"connections",
"native_provider",
new_column_name="provider",
existing_type=sa.String(length=100),
existing_nullable=True,
)
op.alter_column(
"connections",
"provider",
existing_type=sa.String(length=100),
nullable=False,
)
elif not _column_exists("connections", "provider"):
op.add_column(
"connections",
sa.Column("provider", sa.String(length=100), nullable=False),
)
_drop_index_if_exists("connections", "ix_connections_protocol")
_drop_column_if_exists("connections", "protocol")
else:
op.create_table(
"connections",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("provider", sa.String(length=100), nullable=False),
sa.Column("base_url", sa.String(length=500), nullable=True),
sa.Column("api_key", sa.String(), nullable=True),
sa.Column(
"extra",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column("scope", connection_scope, nullable=False),
sa.Column(
"enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False
),
sa.Column("search_space_id", sa.Integer(), nullable=True),
sa.Column("user_id", sa.UUID(), nullable=True),
sa.CheckConstraint(
"(scope = 'GLOBAL' AND search_space_id IS NULL AND user_id IS NULL) OR "
"(scope = 'SEARCH_SPACE' AND search_space_id IS NOT NULL AND user_id IS NOT NULL) OR "
"(scope = 'USER' AND user_id IS NOT NULL)",
name="ck_connections_scope_owner",
),
sa.ForeignKeyConstraint(
["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
if _index_exists(
"connections", "ix_connections_native_provider"
) and not _index_exists("connections", "ix_connections_provider"):
op.execute(
"ALTER INDEX ix_connections_native_provider "
"RENAME TO ix_connections_provider"
)
if _index_exists(
"connections", "ix_connections_litellm_provider"
) and not _index_exists("connections", "ix_connections_provider"):
op.execute(
"ALTER INDEX ix_connections_litellm_provider "
"RENAME TO ix_connections_provider"
)
_create_index_if_missing("ix_connections_provider", "connections", ["provider"])
_create_index_if_missing("ix_connections_scope", "connections", ["scope"])
if not _table_exists("models"):
op.create_table(
"models",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("connection_id", sa.Integer(), nullable=False),
sa.Column("model_id", sa.String(length=255), nullable=False),
sa.Column("display_name", sa.String(length=255), nullable=True),
sa.Column(
"source",
model_source,
server_default="DISCOVERED",
nullable=False,
),
sa.Column("supports_chat", sa.Boolean(), nullable=True),
sa.Column("max_input_tokens", sa.Integer(), nullable=True),
sa.Column("supports_image_input", sa.Boolean(), nullable=True),
sa.Column("supports_tools", sa.Boolean(), nullable=True),
sa.Column("supports_image_generation", sa.Boolean(), nullable=True),
sa.Column(
"capabilities_override",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.Column(
"enabled", sa.Boolean(), server_default=sa.text("true"), nullable=False
),
sa.Column("billing_tier", sa.String(length=50), nullable=True),
sa.Column(
"catalog",
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False,
),
sa.ForeignKeyConstraint(
["connection_id"], ["connections.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"connection_id", "model_id", name="uq_models_connection_model_id"
),
)
else:
if not _column_exists("models", "supports_chat"):
op.add_column(
"models", sa.Column("supports_chat", sa.Boolean(), nullable=True)
)
if not _column_exists("models", "max_input_tokens"):
op.add_column(
"models", sa.Column("max_input_tokens", sa.Integer(), nullable=True)
)
if not _column_exists("models", "supports_image_input"):
op.add_column(
"models", sa.Column("supports_image_input", sa.Boolean(), nullable=True)
)
if not _column_exists("models", "supports_tools"):
op.add_column(
"models", sa.Column("supports_tools", sa.Boolean(), nullable=True)
)
if not _column_exists("models", "supports_image_generation"):
op.add_column(
"models",
sa.Column("supports_image_generation", sa.Boolean(), nullable=True),
)
_drop_column_if_exists("models", "capabilities")
_drop_column_if_exists("models", "capabilities_declared")
_drop_column_if_exists("models", "capabilities_verified")
_create_index_if_missing("ix_models_connection_id", "models", ["connection_id"])
_create_index_if_missing("ix_models_model_id", "models", ["model_id"])
_create_index_if_missing("ix_models_billing_tier", "models", ["billing_tier"])
_add_searchspace_column_if_missing("chat_model_id", server_default=sa.text("0"))
_add_searchspace_column_if_missing(
"image_gen_model_id", server_default=sa.text("0")
)
_add_searchspace_column_if_missing("vision_model_id", server_default=sa.text("0"))
for column_name in ("chat_model_id", "image_gen_model_id", "vision_model_id"):
op.alter_column(
"searchspaces",
column_name,
existing_type=sa.Integer(),
existing_nullable=True,
server_default=sa.text("0"),
)
op.execute(
"""
UPDATE searchspaces
SET
chat_model_id = COALESCE(chat_model_id, 0),
image_gen_model_id = COALESCE(image_gen_model_id, 0),
vision_model_id = COALESCE(vision_model_id, 0)
"""
)
op.execute("DROP TYPE IF EXISTS connectionprotocol")
def downgrade() -> None:
op.drop_column("searchspaces", "vision_model_id")
op.drop_column("searchspaces", "image_gen_model_id")
op.drop_column("searchspaces", "chat_model_id")
op.drop_index(op.f("ix_models_billing_tier"), table_name="models")
op.drop_index("ix_models_model_id", table_name="models")
op.drop_index(op.f("ix_models_connection_id"), table_name="models")
op.drop_table("models")
op.drop_index(op.f("ix_connections_scope"), table_name="connections")
op.drop_index(op.f("ix_connections_provider"), table_name="connections")
op.drop_table("connections")
bind = op.get_bind()
model_source.drop(bind, checkfirst=True)
connection_scope.drop(bind, checkfirst=True)

View file

@ -1,270 +0,0 @@
"""remove legacy model config tables
Revision ID: 161
Revises: 160
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from sqlalchemy.types import TypeEngine
from alembic import op
revision: str = "161"
down_revision: str | None = "160"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
litellm_provider = postgresql.ENUM(
"OPENAI",
"ANTHROPIC",
"GOOGLE",
"AZURE_OPENAI",
"BEDROCK",
"VERTEX_AI",
"GROQ",
"COHERE",
"MISTRAL",
"DEEPSEEK",
"XAI",
"OPENROUTER",
"TOGETHER_AI",
"FIREWORKS_AI",
"REPLICATE",
"PERPLEXITY",
"OLLAMA",
"ALIBABA_QWEN",
"MOONSHOT",
"ZHIPU",
"ANYSCALE",
"DEEPINFRA",
"CEREBRAS",
"SAMBANOVA",
"AI21",
"CLOUDFLARE",
"DATABRICKS",
"COMETAPI",
"HUGGINGFACE",
"GITHUB_MODELS",
"MINIMAX",
"CUSTOM",
name="litellmprovider",
create_type=False,
)
image_gen_provider = postgresql.ENUM(
"OPENAI",
"AZURE_OPENAI",
"GOOGLE",
"VERTEX_AI",
"BEDROCK",
"RECRAFT",
"OPENROUTER",
"XINFERENCE",
"NSCALE",
name="imagegenprovider",
create_type=False,
)
vision_provider = postgresql.ENUM(
"OPENAI",
"ANTHROPIC",
"GOOGLE",
"AZURE_OPENAI",
"VERTEX_AI",
"BEDROCK",
"XAI",
"OPENROUTER",
"OLLAMA",
"GROQ",
"TOGETHER_AI",
"FIREWORKS_AI",
"DEEPSEEK",
"MISTRAL",
"CUSTOM",
name="visionprovider",
create_type=False,
)
def _table_exists(table_name: str) -> bool:
return table_name in sa.inspect(op.get_bind()).get_table_names()
def _column_exists(table_name: str, column_name: str) -> bool:
if not _table_exists(table_name):
return False
return column_name in {
column["name"] for column in sa.inspect(op.get_bind()).get_columns(table_name)
}
def _drop_column_if_exists(table_name: str, column_name: str) -> None:
if _column_exists(table_name, column_name):
op.drop_column(table_name, column_name)
def _rename_column_if_exists(
table_name: str,
old_column_name: str,
new_column_name: str,
*,
existing_type: TypeEngine,
existing_nullable: bool = True,
) -> None:
if _column_exists(table_name, old_column_name) and not _column_exists(
table_name, new_column_name
):
op.alter_column(
table_name,
old_column_name,
new_column_name=new_column_name,
existing_type=existing_type,
existing_nullable=existing_nullable,
)
def upgrade() -> None:
for table_name in (
"new_llm_configs",
"vision_llm_configs",
"image_generation_configs",
):
if _table_exists(table_name):
op.drop_table(table_name)
_drop_column_if_exists("searchspaces", "agent_llm_id")
_drop_column_if_exists("searchspaces", "image_generation_config_id")
_drop_column_if_exists("searchspaces", "vision_llm_config_id")
_rename_column_if_exists(
"image_generations",
"image_generation_config_id",
"image_gen_model_id",
existing_type=sa.Integer(),
)
op.execute("DROP TYPE IF EXISTS litellmprovider")
op.execute("DROP TYPE IF EXISTS imagegenprovider")
op.execute("DROP TYPE IF EXISTS visionprovider")
def downgrade() -> None:
bind = op.get_bind()
litellm_provider.create(bind, checkfirst=True)
image_gen_provider.create(bind, checkfirst=True)
vision_provider.create(bind, checkfirst=True)
_rename_column_if_exists(
"image_generations",
"image_gen_model_id",
"image_generation_config_id",
existing_type=sa.Integer(),
)
if _table_exists("searchspaces"):
if not _column_exists("searchspaces", "agent_llm_id"):
op.add_column(
"searchspaces",
sa.Column("agent_llm_id", sa.Integer(), nullable=True),
)
if not _column_exists("searchspaces", "image_generation_config_id"):
op.add_column(
"searchspaces",
sa.Column("image_generation_config_id", sa.Integer(), nullable=True),
)
if not _column_exists("searchspaces", "vision_llm_config_id"):
op.add_column(
"searchspaces",
sa.Column("vision_llm_config_id", sa.Integer(), nullable=True),
)
if not _table_exists("image_generation_configs"):
op.create_table(
"image_generation_configs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=500), nullable=True),
sa.Column("provider", image_gen_provider, nullable=False),
sa.Column("custom_provider", sa.String(length=100), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=False),
sa.Column("api_key", sa.String(), nullable=False),
sa.Column("api_base", sa.String(length=500), nullable=True),
sa.Column("api_version", sa.String(length=50), nullable=True),
sa.Column("litellm_params", sa.JSON(), nullable=True),
sa.Column("search_space_id", sa.Integer(), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_image_generation_configs_name"),
"image_generation_configs",
["name"],
unique=False,
)
if not _table_exists("vision_llm_configs"):
op.create_table(
"vision_llm_configs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=500), nullable=True),
sa.Column("provider", vision_provider, nullable=False),
sa.Column("custom_provider", sa.String(length=100), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=False),
sa.Column("api_key", sa.String(), nullable=False),
sa.Column("api_base", sa.String(length=500), nullable=True),
sa.Column("api_version", sa.String(length=50), nullable=True),
sa.Column("litellm_params", sa.JSON(), nullable=True),
sa.Column("search_space_id", sa.Integer(), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_vision_llm_configs_name"),
"vision_llm_configs",
["name"],
unique=False,
)
if not _table_exists("new_llm_configs"):
op.create_table(
"new_llm_configs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("description", sa.String(length=500), nullable=True),
sa.Column("provider", litellm_provider, nullable=False),
sa.Column("custom_provider", sa.String(length=100), nullable=True),
sa.Column("model_name", sa.String(length=100), nullable=False),
sa.Column("api_key", sa.String(), nullable=False),
sa.Column("api_base", sa.String(length=500), nullable=True),
sa.Column("litellm_params", sa.JSON(), nullable=True),
sa.Column("system_instructions", sa.Text(), nullable=False),
sa.Column("use_default_system_instructions", sa.Boolean(), nullable=False),
sa.Column("citations_enabled", sa.Boolean(), nullable=False),
sa.Column("search_space_id", sa.Integer(), nullable=False),
sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False),
sa.ForeignKeyConstraint(
["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_new_llm_configs_name"),
"new_llm_configs",
["name"],
unique=False,
)

View file

@ -1,53 +0,0 @@
"""add etl_cache_parses table for content-addressed parse reuse
Revision ID: 162
Revises: 161
"""
from collections.abc import Sequence
from alembic import op
revision: str = "162"
down_revision: str | None = "161"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS etl_cache_parses (
id SERIAL PRIMARY KEY,
source_sha256 VARCHAR(64) NOT NULL,
etl_service VARCHAR(32) NOT NULL,
mode VARCHAR(16) NOT NULL,
parser_version INTEGER NOT NULL,
storage_backend VARCHAR(32) NOT NULL,
storage_key TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
content_type VARCHAR(32) NOT NULL,
actual_pages INTEGER NOT NULL DEFAULT 0,
times_reused BIGINT NOT NULL DEFAULT 0,
last_used_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_etl_cache_parses_key
UNIQUE (source_sha256, etl_service, mode, parser_version)
);
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_etl_cache_parses_last_used_at "
"ON etl_cache_parses(last_used_at);"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_etl_cache_parses_created_at "
"ON etl_cache_parses(created_at);"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_etl_cache_parses_created_at;")
op.execute("DROP INDEX IF EXISTS ix_etl_cache_parses_last_used_at;")
op.execute("DROP TABLE IF EXISTS etl_cache_parses;")

View file

@ -1,53 +0,0 @@
"""add embedding_cache_sets table for content-addressed embedding reuse
Revision ID: 163
Revises: 162
"""
from collections.abc import Sequence
from alembic import op
revision: str = "163"
down_revision: str | None = "162"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS embedding_cache_sets (
id SERIAL PRIMARY KEY,
markdown_sha256 VARCHAR(64) NOT NULL,
embedding_model VARCHAR(255) NOT NULL,
embedding_dim INTEGER NOT NULL,
chunker_kind VARCHAR(8) NOT NULL,
chunker_version INTEGER NOT NULL,
storage_backend VARCHAR(32) NOT NULL,
storage_key TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
chunk_count INTEGER NOT NULL DEFAULT 0,
times_reused BIGINT NOT NULL DEFAULT 0,
last_used_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
CONSTRAINT uq_embedding_cache_sets_key
UNIQUE (markdown_sha256, embedding_model, chunker_kind, chunker_version)
);
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_embedding_cache_sets_last_used_at "
"ON embedding_cache_sets(last_used_at);"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_embedding_cache_sets_created_at "
"ON embedding_cache_sets(created_at);"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_embedding_cache_sets_created_at;")
op.execute("DROP INDEX IF EXISTS ix_embedding_cache_sets_last_used_at;")
op.execute("DROP TABLE IF EXISTS embedding_cache_sets;")

View file

@ -1,219 +0,0 @@
"""remove users that never logged back in (last_login IS NULL)
Migration 103 added ``user.last_login``. Any user whose ``last_login`` is still
NULL has never authenticated since that column existed, i.e. they never logged
back in. This migration purges those users together with everything that hangs
off them: the search spaces they own, and (via ON DELETE CASCADE)
``searchspaces -> documents -> chunks`` plus all other user/space-scoped rows.
This runs BEFORE the chunks.position backfill (revision 165) on purpose: it
removes a large amount of dead chunk data first, so the expensive backfill has
far fewer rows to rewrite.
Work is done in committed batches (not one giant cascading DELETE) so that on a
large table it streams progress to the alembic console, keeps each transaction
small, bounds WAL/bloat growth, and is resumable if interrupted.
Revision ID: 164
Revises: 163
"""
import logging
import time
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "164"
down_revision: str | None = "163"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Documents removed per committed batch. Each document delete cascades to its
# chunks (via ix_chunks_document_id), so keep this modest to bound batch size.
DOC_BATCH = 1_000
# Users removed per committed batch. Each cascades to owned search spaces and
# the remaining space-/user-scoped rows.
USER_BATCH = 500
# Minimum seconds between progress log lines (keeps the console readable).
LOG_EVERY_SECONDS = 5.0
USER_SCRATCH = "_inactive_user_ids"
DOC_SCRATCH = "_inactive_doc_ids"
logger = logging.getLogger("alembic.runtime.migration")
def _fmt_duration(seconds: float) -> str:
seconds = int(seconds)
h, rem = divmod(seconds, 3600)
m, s = divmod(rem, 60)
if h:
return f"{h}h{m:02d}m{s:02d}s"
if m:
return f"{m}m{s:02d}s"
return f"{s}s"
def upgrade() -> None:
bind = op.get_bind()
# Run the heavy work outside the migration's single transaction so each
# batch can commit on its own.
with op.get_context().autocommit_block():
# Materialize the target user ids once. Rebuilt from scratch on every
# run, so a re-run after an interruption simply picks up whoever still
# has NULL last_login -> the migration is idempotent and resumable.
op.execute(f"DROP TABLE IF EXISTS {USER_SCRATCH};")
op.execute(
f"CREATE UNLOGGED TABLE {USER_SCRATCH} AS "
'SELECT id FROM "user" WHERE last_login IS NULL;'
)
op.execute(f"ALTER TABLE {USER_SCRATCH} ADD PRIMARY KEY (id);")
total_users = (
bind.execute(sa.text(f"SELECT count(*) FROM {USER_SCRATCH}")).scalar() or 0
)
if total_users == 0:
logger.info("no users with NULL last_login; nothing to remove")
op.execute(f"DROP TABLE IF EXISTS {USER_SCRATCH};")
return
logger.info(
"found %s users with NULL last_login (never logged back in); "
"removing them and all data in search spaces they own",
f"{total_users:,}",
)
# Documents living in search spaces owned by those users. Deleting these
# explicitly (in batches) is what bounds the otherwise-unbounded
# chunks cascade.
op.execute(f"DROP TABLE IF EXISTS {DOC_SCRATCH};")
op.execute(
f"""
CREATE UNLOGGED TABLE {DOC_SCRATCH} AS
SELECT d.id
FROM documents d
JOIN searchspaces s ON s.id = d.search_space_id
WHERE s.user_id IN (SELECT id FROM {USER_SCRATCH});
"""
)
op.execute(f"ALTER TABLE {DOC_SCRATCH} ADD PRIMARY KEY (id);")
total_docs = (
bind.execute(sa.text(f"SELECT count(*) FROM {DOC_SCRATCH}")).scalar() or 0
)
# Phase 1: delete documents (cascades chunks, document_versions,
# document_files) in committed batches.
logger.info(
"phase 1/2: deleting %s documents (cascades their chunks) "
"in batches of %s...",
f"{total_docs:,}",
f"{DOC_BATCH:,}",
)
_batched_delete(
bind,
scratch=DOC_SCRATCH,
target_table="documents",
target_col="id",
batch_size=DOC_BATCH,
total=total_docs,
label="documents",
)
op.execute(f"DROP TABLE IF EXISTS {DOC_SCRATCH};")
# Phase 2: delete the users themselves. This cascades the now-empty
# search spaces plus all remaining user-/space-scoped rows.
logger.info(
"phase 2/2: deleting %s users (cascades search spaces and "
"remaining data) in batches of %s...",
f"{total_users:,}",
f"{USER_BATCH:,}",
)
_batched_delete(
bind,
scratch=USER_SCRATCH,
target_table='"user"',
target_col="id",
batch_size=USER_BATCH,
total=total_users,
label="users",
)
op.execute(f"DROP TABLE IF EXISTS {USER_SCRATCH};")
logger.info("migration 164 finished")
def _batched_delete(
bind: sa.engine.Connection,
*,
scratch: str,
target_table: str,
target_col: str,
batch_size: int,
total: int,
label: str,
) -> None:
"""Pop ids from ``scratch`` and delete the matching rows, one committed
batch at a time, logging progress. Atomic per batch: the row delete and the
scratch pop happen in a single statement, so an interrupted run leaves the
scratch table in sync with what has actually been deleted."""
started = time.monotonic()
last_log = 0.0
done = 0
stmt = sa.text(
f"""
WITH batch AS (
SELECT id FROM {scratch} LIMIT :n
), deleted AS (
DELETE FROM {target_table}
WHERE {target_col} IN (SELECT id FROM batch)
), popped AS (
DELETE FROM {scratch}
WHERE id IN (SELECT id FROM batch)
RETURNING id
)
SELECT count(*) FROM popped
"""
)
while True:
popped = bind.execute(stmt, {"n": batch_size}).scalar() or 0
if popped == 0:
break
done += popped
now = time.monotonic()
if now - last_log >= LOG_EVERY_SECONDS or done >= total:
elapsed = now - started
pct = (100.0 * done / total) if total else 100.0
eta = (elapsed / pct * (100.0 - pct)) if pct > 0 else 0.0
logger.info(
"%s deleted: %.1f%% (%s/%s) elapsed %s eta %s",
label,
pct,
f"{done:,}",
f"{total:,}",
_fmt_duration(elapsed),
_fmt_duration(eta),
)
last_log = now
logger.info(
"deleted %s %s in %s",
f"{done:,}",
label,
_fmt_duration(time.monotonic() - started),
)
def downgrade() -> None:
# Irreversible: deleted users and their cascaded data cannot be restored.
# No-op so the downgrade chain can still pass through this revision.
logger.warning(
"migration 164 (remove_inactive_users) is irreversible; "
"downgrade is a no-op (deleted users/data are not restored)"
)

View file

@ -1,49 +0,0 @@
"""add chunks.position for explicit document order
Incremental re-indexing keeps unchanged chunk rows, so auto-increment ids no
longer reflect document order. The ``position`` column makes that order
explicit and is written by the indexing pipeline for every new or re-indexed
document.
This migration intentionally does NOT backfill historical rows. On a large,
heavily-indexed table (notably a multi-hundred-GB HNSW embedding index) a bulk
UPDATE of every chunk becomes a non-HOT update that rewrites every secondary
index per row -- turning a one-off migration into a multi-day operation.
Instead, existing rows keep ``position = 0`` and therefore order by the
``Chunk.id`` tiebreaker (identical to the pre-feature behavior); new and
re-indexed documents get correct positions from application code, and any
document needing exact order can simply be re-indexed on demand.
Revision ID: 165
Revises: 164
"""
from collections.abc import Sequence
from alembic import op
revision: str = "165"
down_revision: str | None = "164"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Leftover UNLOGGED scratch table from earlier backfill attempts; dropped here
# so re-running this migration converges the schema regardless of past state.
SCRATCH_TABLE = "_chunk_position_backfill"
def upgrade() -> None:
# Adding a NOT NULL column with a constant default is metadata-only on
# PostgreSQL 11+, so this is fast even on very large tables. IF NOT EXISTS
# makes it a no-op where the column already exists.
op.execute(
"ALTER TABLE chunks ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0;"
)
# Clean up the scratch table left behind by the abandoned backfill approach.
op.execute(f"DROP TABLE IF EXISTS {SCRATCH_TABLE};")
def downgrade() -> None:
op.execute(f"DROP TABLE IF EXISTS {SCRATCH_TABLE};")
op.execute("ALTER TABLE chunks DROP COLUMN IF EXISTS position;")

View file

@ -1,81 +0,0 @@
"""Add personal access tokens and search-space API access gate.
Revision ID: 166
Revises: 165
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "166"
down_revision: str | None = "165"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS personal_access_tokens (
id SERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL,
token_prefix VARCHAR(16) NOT NULL,
label VARCHAR NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
"""
)
op.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ix_personal_access_tokens_token_hash "
"ON personal_access_tokens (token_hash)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_user_id "
"ON personal_access_tokens (user_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_id "
"ON personal_access_tokens (id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_created_at "
"ON personal_access_tokens (created_at)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_personal_access_tokens_expires_at "
"ON personal_access_tokens (expires_at)"
)
bind = op.get_bind()
api_access_column_exists = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'searchspaces'
AND column_name = 'api_access_enabled'
)
"""
)
).scalar()
op.execute(
"ALTER TABLE searchspaces ADD COLUMN IF NOT EXISTS "
"api_access_enabled BOOLEAN NOT NULL DEFAULT false"
)
if not api_access_column_exists:
op.execute("UPDATE searchspaces SET api_access_enabled = true")
def downgrade() -> None:
op.execute("ALTER TABLE searchspaces DROP COLUMN IF EXISTS api_access_enabled")
op.execute("DROP TABLE IF EXISTS personal_access_tokens")

View file

@ -1,23 +0,0 @@
"""publish Zero authz parent tables
Revision ID: 167
Revises: 166
"""
from collections.abc import Sequence
from alembic import op
from app.zero_publication import apply_publication
revision: str = "167"
down_revision: str | None = "166"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
apply_publication(op.get_bind())
def downgrade() -> None:
"""No-op. Historical publication shapes are immutable."""

View file

@ -1,79 +0,0 @@
"""harden refresh token schema
Revision ID: 168
Revises: 167
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "168"
down_revision: str | None = "167"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS "
"revoked_at TIMESTAMP WITH TIME ZONE"
)
op.execute(
"ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS "
"absolute_expiry TIMESTAMP WITH TIME ZONE"
)
bind = op.get_bind()
is_revoked_exists = bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'refresh_tokens'
AND column_name = 'is_revoked'
)
"""
)
).scalar()
if is_revoked_exists:
op.execute(
"""
UPDATE refresh_tokens
SET revoked_at = NOW()
WHERE is_revoked = TRUE
AND revoked_at IS NULL
"""
)
op.execute("ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(64)")
op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS is_revoked")
def downgrade() -> None:
op.execute(
"ALTER TABLE refresh_tokens ADD COLUMN IF NOT EXISTS "
"is_revoked BOOLEAN NOT NULL DEFAULT false"
)
op.execute(
"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'refresh_tokens'
AND column_name = 'revoked_at'
) THEN
UPDATE refresh_tokens SET is_revoked = TRUE WHERE revoked_at IS NOT NULL;
END IF;
END $$;
"""
)
op.execute("ALTER TABLE refresh_tokens ALTER COLUMN is_revoked DROP DEFAULT")
op.execute("ALTER TABLE refresh_tokens ALTER COLUMN token_hash TYPE VARCHAR(256)")
op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS absolute_expiry")
op.execute("ALTER TABLE refresh_tokens DROP COLUMN IF EXISTS revoked_at")

View file

@ -1,74 +0,0 @@
"""migrate Google OAuth account IDs to sub
Revision ID: 169
Revises: 168
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "169"
down_revision: str | None = "168"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _oauth_account_table_exists() -> bool:
bind = op.get_bind()
return bool(
bind.execute(
sa.text(
"""
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'oauth_account'
)
"""
)
).scalar()
)
def upgrade() -> None:
if not _oauth_account_table_exists():
return
op.execute(
"""
UPDATE oauth_account AS legacy
SET account_id = regexp_replace(legacy.account_id, '^people/', '')
WHERE legacy.oauth_name = 'google'
AND legacy.account_id LIKE 'people/%'
AND NOT EXISTS (
SELECT 1
FROM oauth_account AS canonical
WHERE canonical.oauth_name = 'google'
AND canonical.account_id = regexp_replace(legacy.account_id, '^people/', '')
)
"""
)
def downgrade() -> None:
if not _oauth_account_table_exists():
return
op.execute(
"""
UPDATE oauth_account AS canonical
SET account_id = 'people/' || canonical.account_id
WHERE canonical.oauth_name = 'google'
AND canonical.account_id NOT LIKE 'people/%'
AND NOT EXISTS (
SELECT 1
FROM oauth_account AS legacy
WHERE legacy.oauth_name = 'google'
AND legacy.account_id = 'people/' || canonical.account_id
)
"""
)

View file

@ -1,512 +0,0 @@
"""rename searchspace schema to workspace
Renames the SearchSpace tables/columns/constraints/indexes/sequences to the
workspace_* names the ORM already declares, and reconciles the Zero publication
to match.
Revision ID: 170
Revises: 169
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from app.zero_publication import apply_publication
revision: str = "170"
down_revision: str | None = "169"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
PUBLICATION_NAME = "zero_publication"
# Tables whose published column list references the renamed column. Their
# column-list dependency must be removed before RENAME COLUMN is permitted,
# then re-added by apply_publication with the new column name.
PUBLICATION_COLUMN_LIST_TABLES = [
"documents",
"automations",
"new_chat_threads",
"podcasts",
]
# (old_table, new_table)
TABLE_RENAMES: list[tuple[str, str]] = [
("searchspaces", "workspaces"),
("search_space_roles", "workspace_roles"),
("search_space_memberships", "workspace_memberships"),
("search_space_invites", "workspace_invites"),
]
# (table, old_column, new_column) -- table names are POST-table-rename.
COLUMN_RENAMES: list[tuple[str, str, str]] = [
("agent_action_log", "search_space_id", "workspace_id"),
("agent_permission_rules", "search_space_id", "workspace_id"),
("automations", "search_space_id", "workspace_id"),
("connections", "search_space_id", "workspace_id"),
("document_files", "search_space_id", "workspace_id"),
("document_revisions", "search_space_id", "workspace_id"),
("documents", "search_space_id", "workspace_id"),
("external_chat_bindings", "search_space_id", "workspace_id"),
("folder_revisions", "search_space_id", "workspace_id"),
("folders", "search_space_id", "workspace_id"),
("image_generations", "search_space_id", "workspace_id"),
("logs", "search_space_id", "workspace_id"),
("new_chat_threads", "search_space_id", "workspace_id"),
("notifications", "search_space_id", "workspace_id"),
("podcasts", "search_space_id", "workspace_id"),
("prompts", "search_space_id", "workspace_id"),
("reports", "search_space_id", "workspace_id"),
("search_source_connectors", "search_space_id", "workspace_id"),
("token_usage", "search_space_id", "workspace_id"),
("video_presentations", "search_space_id", "workspace_id"),
("external_chat_accounts", "owner_search_space_id", "owner_workspace_id"),
("workspace_roles", "search_space_id", "workspace_id"),
("workspace_memberships", "search_space_id", "workspace_id"),
("workspace_invites", "search_space_id", "workspace_id"),
]
# (table, old_constraint, new_constraint) -- table names are POST-table-rename.
CONSTRAINT_RENAMES: list[tuple[str, str, str]] = [
(
"agent_action_log",
"agent_action_log_search_space_id_fkey",
"agent_action_log_workspace_id_fkey",
),
(
"agent_permission_rules",
"agent_permission_rules_search_space_id_fkey",
"agent_permission_rules_workspace_id_fkey",
),
(
"automations",
"automations_search_space_id_fkey",
"automations_workspace_id_fkey",
),
(
"connections",
"connections_search_space_id_fkey",
"connections_workspace_id_fkey",
),
(
"document_files",
"document_files_search_space_id_fkey",
"document_files_workspace_id_fkey",
),
(
"document_revisions",
"document_revisions_search_space_id_fkey",
"document_revisions_workspace_id_fkey",
),
("documents", "documents_search_space_id_fkey", "documents_workspace_id_fkey"),
(
"external_chat_accounts",
"external_chat_accounts_owner_search_space_id_fkey",
"external_chat_accounts_owner_workspace_id_fkey",
),
(
"external_chat_bindings",
"external_chat_bindings_search_space_id_fkey",
"external_chat_bindings_workspace_id_fkey",
),
(
"folder_revisions",
"folder_revisions_search_space_id_fkey",
"folder_revisions_workspace_id_fkey",
),
("folders", "folders_search_space_id_fkey", "folders_workspace_id_fkey"),
(
"image_generations",
"image_generations_search_space_id_fkey",
"image_generations_workspace_id_fkey",
),
("logs", "logs_search_space_id_fkey", "logs_workspace_id_fkey"),
(
"new_chat_threads",
"new_chat_threads_search_space_id_fkey",
"new_chat_threads_workspace_id_fkey",
),
(
"notifications",
"notifications_search_space_id_fkey",
"notifications_workspace_id_fkey",
),
("podcasts", "podcasts_search_space_id_fkey", "podcasts_workspace_id_fkey"),
("prompts", "prompts_search_space_id_fkey", "prompts_workspace_id_fkey"),
("reports", "reports_search_space_id_fkey", "reports_workspace_id_fkey"),
(
"search_source_connectors",
"search_source_connectors_search_space_id_fkey",
"search_source_connectors_workspace_id_fkey",
),
(
"search_source_connectors",
"uq_searchspace_user_connector_type_name",
"uq_workspace_user_connector_type_name",
),
(
"token_usage",
"token_usage_search_space_id_fkey",
"token_usage_workspace_id_fkey",
),
(
"video_presentations",
"video_presentations_search_space_id_fkey",
"video_presentations_workspace_id_fkey",
),
(
"workspace_invites",
"search_space_invites_created_by_id_fkey",
"workspace_invites_created_by_id_fkey",
),
(
"workspace_invites",
"search_space_invites_pkey",
"workspace_invites_pkey",
),
(
"workspace_invites",
"search_space_invites_role_id_fkey",
"workspace_invites_role_id_fkey",
),
(
"workspace_invites",
"search_space_invites_search_space_id_fkey",
"workspace_invites_workspace_id_fkey",
),
(
"workspace_memberships",
"search_space_memberships_invited_by_invite_id_fkey",
"workspace_memberships_invited_by_invite_id_fkey",
),
(
"workspace_memberships",
"search_space_memberships_pkey",
"workspace_memberships_pkey",
),
(
"workspace_memberships",
"search_space_memberships_role_id_fkey",
"workspace_memberships_role_id_fkey",
),
(
"workspace_memberships",
"search_space_memberships_search_space_id_fkey",
"workspace_memberships_workspace_id_fkey",
),
(
"workspace_memberships",
"search_space_memberships_user_id_fkey",
"workspace_memberships_user_id_fkey",
),
(
"workspace_memberships",
"uq_user_searchspace_membership",
"uq_user_workspace_membership",
),
(
"workspace_roles",
"search_space_roles_pkey",
"workspace_roles_pkey",
),
(
"workspace_roles",
"search_space_roles_search_space_id_fkey",
"workspace_roles_workspace_id_fkey",
),
(
"workspace_roles",
"uq_searchspace_role_name",
"uq_workspace_role_name",
),
("workspaces", "searchspaces_pkey", "workspaces_pkey"),
("workspaces", "searchspaces_user_id_fkey", "workspaces_user_id_fkey"),
]
# (old_index, new_index) -- plain indexes only; PK/unique-backed indexes follow
# their RENAME CONSTRAINT above. ALTER INDEX IF EXISTS covers both create_all
# indexes and the runtime ``setup_indexes()`` ones (idx_documents_*).
INDEX_RENAMES: list[tuple[str, str]] = [
("ix_agent_action_log_search_space_id", "ix_agent_action_log_workspace_id"),
(
"ix_agent_permission_rules_search_space_id",
"ix_agent_permission_rules_workspace_id",
),
("ix_automations_search_space_id", "ix_automations_workspace_id"),
("ix_document_files_search_space_id", "ix_document_files_workspace_id"),
("ix_document_revisions_search_space_id", "ix_document_revisions_workspace_id"),
(
"ix_external_chat_bindings_search_space_state",
"ix_external_chat_bindings_workspace_state",
),
("ix_folder_revisions_search_space_id", "ix_folder_revisions_workspace_id"),
("ix_folders_search_space_id", "ix_folders_workspace_id"),
("ix_notifications_search_space_id", "ix_notifications_workspace_id"),
("ix_notifications_user_space_created", "ix_notifications_user_workspace_created"),
("ix_prompts_search_space_id", "ix_prompts_workspace_id"),
("ix_token_usage_search_space_id", "ix_token_usage_workspace_id"),
("ix_search_space_invites_created_at", "ix_workspace_invites_created_at"),
("ix_search_space_invites_id", "ix_workspace_invites_id"),
("ix_search_space_invites_invite_code", "ix_workspace_invites_invite_code"),
("ix_search_space_memberships_created_at", "ix_workspace_memberships_created_at"),
("ix_search_space_memberships_id", "ix_workspace_memberships_id"),
("ix_search_space_roles_created_at", "ix_workspace_roles_created_at"),
("ix_search_space_roles_id", "ix_workspace_roles_id"),
("ix_search_space_roles_name", "ix_workspace_roles_name"),
("ix_searchspaces_created_at", "ix_workspaces_created_at"),
("ix_searchspaces_id", "ix_workspaces_id"),
("ix_searchspaces_name", "ix_workspaces_name"),
("idx_documents_search_space_id", "idx_documents_workspace_id"),
("idx_documents_search_space_updated", "idx_documents_workspace_updated"),
]
# (old_sequence, new_sequence)
SEQUENCE_RENAMES: list[tuple[str, str]] = [
("searchspaces_id_seq", "workspaces_id_seq"),
("search_space_roles_id_seq", "workspace_roles_id_seq"),
("search_space_memberships_id_seq", "workspace_memberships_id_seq"),
("search_space_invites_id_seq", "workspace_invites_id_seq"),
]
# ---- Hardcoded OLD-shape publication (for downgrade only; finding 7). ----
# Mirrors app.zero_publication.ZERO_PUBLICATION at revision 169 but with the
# pre-rename ``search_space_id`` column. NEVER import the live module here: it
# now reflects ``workspace_id`` and would silently drop the column-list tables.
_DOWNGRADE_DOCUMENT_COLS = [
"id",
"title",
"document_type",
"search_space_id",
"folder_id",
"created_by_id",
"status",
"created_at",
"updated_at",
]
_DOWNGRADE_USER_COLS = ["id", "credit_micros_balance"]
_DOWNGRADE_AUTOMATION_RUN_COLS = [
"id",
"automation_id",
"trigger_id",
"status",
"step_results",
"started_at",
"finished_at",
"created_at",
]
_DOWNGRADE_AUTOMATION_COLS = ["id", "search_space_id"]
_DOWNGRADE_NEW_CHAT_THREAD_COLS = ["id", "search_space_id"]
_DOWNGRADE_PODCAST_COLS = [
"id",
"title",
"status",
"spec",
"spec_version",
"duration_seconds",
"error",
"search_space_id",
"thread_id",
"created_at",
]
# Ordered to match ZERO_PUBLICATION; None == all columns.
_DOWNGRADE_PUBLICATION: list[tuple[str, list[str] | None]] = [
("notifications", None),
("documents", _DOWNGRADE_DOCUMENT_COLS),
("folders", None),
("search_source_connectors", None),
("new_chat_threads", _DOWNGRADE_NEW_CHAT_THREAD_COLS),
("new_chat_messages", None),
("chat_comments", None),
("chat_session_state", None),
("user", _DOWNGRADE_USER_COLS),
("automations", _DOWNGRADE_AUTOMATION_COLS),
("automation_runs", _DOWNGRADE_AUTOMATION_RUN_COLS),
("podcasts", _DOWNGRADE_PODCAST_COLS),
]
def _quote(identifier: str) -> str:
return '"' + identifier.replace('"', '""') + '"'
def _publication_exists(conn) -> bool:
return (
conn.execute(
sa.text("SELECT 1 FROM pg_publication WHERE pubname = :n"),
{"n": PUBLICATION_NAME},
).fetchone()
is not None
)
def _is_publication_member(conn, table: str) -> bool:
return (
conn.execute(
sa.text(
"SELECT 1 FROM pg_publication_tables "
"WHERE pubname = :n AND schemaname = current_schema() "
"AND tablename = :t"
),
{"n": PUBLICATION_NAME, "t": table},
).fetchone()
is not None
)
def _table_columns(conn, table: str) -> set[str]:
rows = conn.execute(
sa.text(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = current_schema() AND table_name = :t"
),
{"t": table},
).fetchall()
return {row[0] for row in rows}
def _neutralize_column_list_tables(conn) -> None:
"""Remove the column-list dependency so RENAME COLUMN is permitted."""
if not _publication_exists(conn):
return
for table in PUBLICATION_COLUMN_LIST_TABLES:
if _is_publication_member(conn, table):
conn.execute(
sa.text(
f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} DROP TABLE {table}"
)
)
def _rename_table(conn, old: str, new: str) -> None:
conn.execute(sa.text(f"ALTER TABLE IF EXISTS {old} RENAME TO {new}"))
def _rename_column(conn, table: str, old: str, new: str) -> None:
conn.execute(
sa.text(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = '{table}' AND column_name = '{old}'
) THEN
ALTER TABLE {table} RENAME COLUMN {old} TO {new};
END IF;
END$$;
"""
)
)
def _rename_constraint(conn, table: str, old: str, new: str) -> None:
conn.execute(
sa.text(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = '{old}'
AND conrelid = to_regclass('{table}')
) THEN
ALTER TABLE {table} RENAME CONSTRAINT {old} TO {new};
END IF;
END$$;
"""
)
)
def _rename_index(conn, old: str, new: str) -> None:
conn.execute(sa.text(f"ALTER INDEX IF EXISTS {old} RENAME TO {new}"))
def _rename_sequence(conn, old: str, new: str) -> None:
conn.execute(sa.text(f"ALTER SEQUENCE IF EXISTS {old} RENAME TO {new}"))
def _restore_downgrade_publication(conn) -> None:
"""Re-emit the OLD (search_space_id) publication shape via plain SET TABLE.
Does NOT call apply_publication (which reads the live, now-workspace_id
module) -- that would silently drop the column-list tables (finding 7).
"""
if not _publication_exists(conn):
return
# Drop the column-list tables first so the SET TABLE has no stale
# workspace_id dependency to trip over.
for table in PUBLICATION_COLUMN_LIST_TABLES:
if _is_publication_member(conn, table):
conn.execute(
sa.text(
f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} DROP TABLE {table}"
)
)
entries: list[str] = []
for table, cols in _DOWNGRADE_PUBLICATION:
actual = _table_columns(conn, table)
if not actual:
continue
if cols is None:
entries.append(_quote(table))
continue
expected = list(cols)
if table in {"documents", "user", "podcasts"} and "_0_version" in actual:
expected.append("_0_version")
if any(col not in actual for col in expected):
continue
col_sql = ", ".join(_quote(col) for col in expected)
entries.append(f"{_quote(table)} ({col_sql})")
table_list = ", ".join(entries)
conn.execute(
sa.text(f"ALTER PUBLICATION {_quote(PUBLICATION_NAME)} SET TABLE {table_list}")
)
def upgrade() -> None:
conn = op.get_bind()
_neutralize_column_list_tables(conn)
for old, new in TABLE_RENAMES:
_rename_table(conn, old, new)
for table, old, new in COLUMN_RENAMES:
_rename_column(conn, table, old, new)
for table, old, new in CONSTRAINT_RENAMES:
_rename_constraint(conn, table, old, new)
for old, new in INDEX_RENAMES:
_rename_index(conn, old, new)
for old, new in SEQUENCE_RENAMES:
_rename_sequence(conn, old, new)
# Reconcile to the new workspace_id shape via the blessed apply_publication
# path (ALTER ... SET TABLE) -- never raw DROP/CREATE PUBLICATION (bug #1355,
# migration 116). No-op if the publication does not exist.
apply_publication(conn)
def downgrade() -> None:
conn = op.get_bind()
_neutralize_column_list_tables(conn)
for old, new in SEQUENCE_RENAMES:
_rename_sequence(conn, new, old)
for old, new in INDEX_RENAMES:
_rename_index(conn, new, old)
for table, old, new in CONSTRAINT_RENAMES:
# table here is the POST-rename name; constraints/tables are still
# renamed at this point (table rename is reversed last).
_rename_constraint(conn, table, new, old)
for table, old, new in COLUMN_RENAMES:
_rename_column(conn, table, new, old)
for old, new in TABLE_RENAMES:
_rename_table(conn, new, old)
_restore_downgrade_publication(conn)

View file

@ -1,78 +0,0 @@
"""Add runs and tool_output_spills tables.
``runs`` backs the user-facing Scraper-API logs and the agent's tool-boundary
truncation (full scraper output stored here, model sees a capped preview + id).
``tool_output_spills`` is the internal scratch store for main-agent context
spills, kept separate so the customer log stays clean.
Revision ID: 171
Revises: 170
"""
from collections.abc import Sequence
from alembic import op
revision: str = "171"
down_revision: str | None = "170"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE IF NOT EXISTS runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID REFERENCES "user"(id) ON DELETE CASCADE,
thread_id VARCHAR(255),
capability VARCHAR(100) NOT NULL,
origin VARCHAR(16) NOT NULL,
status VARCHAR(16) NOT NULL,
error TEXT,
input JSONB,
output_text TEXT,
item_count INTEGER NOT NULL DEFAULT 0,
char_count INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER,
cost_micros BIGINT,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
"""
)
op.execute("CREATE INDEX IF NOT EXISTS ix_runs_workspace_id ON runs (workspace_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_runs_user_id ON runs (user_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_runs_capability ON runs (capability)")
op.execute("CREATE INDEX IF NOT EXISTS ix_runs_created_at ON runs (created_at)")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_runs_workspace_created "
"ON runs (workspace_id, created_at)"
)
op.execute(
"""
CREATE TABLE IF NOT EXISTS tool_output_spills (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
thread_id VARCHAR(255),
tool_name VARCHAR(255),
content TEXT NOT NULL,
char_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
"""
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_tool_output_spills_workspace_id "
"ON tool_output_spills (workspace_id)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_tool_output_spills_created_at "
"ON tool_output_spills (created_at)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS tool_output_spills")
op.execute("DROP TABLE IF EXISTS runs")

View file

@ -1,32 +0,0 @@
"""Remove AI file sort flag.
Revision ID: 172
Revises: 171
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "172"
down_revision: str | None = "171"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_column("workspaces", "ai_file_sort_enabled")
def downgrade() -> None:
op.add_column(
"workspaces",
sa.Column(
"ai_file_sort_enabled",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)

View file

@ -1,26 +0,0 @@
"""Add runs.progress for streamed scraper run progress.
Stores the coarse (throttled) progress log captured during a run; the live
fine-grained stream stays ephemeral (in-process bus / SSE only). Nullable so
existing rows and the sync/agent doors that don't report progress are unaffected.
Revision ID: 173
Revises: 172
"""
from collections.abc import Sequence
from alembic import op
revision: str = "173"
down_revision: str | None = "172"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute("ALTER TABLE runs ADD COLUMN IF NOT EXISTS progress JSONB")
def downgrade() -> None:
op.execute("ALTER TABLE runs DROP COLUMN IF EXISTS progress")

View file

@ -1,16 +1,16 @@
"""Minimal anonymous / free-chat agent.
The no-login chat experience must stay dead simple: the user asks a question
and the model answers over an optionally uploaded **read-only** document. We
deliberately bypass the full SurfSense deep agent stack (filesystem,
file-intent, knowledge-base persistence, subagents, skills, memory) because
those middlewares stage or persist "documents" that an anonymous session can
never see again -- which produced phantom "I saved it to a file" answers for
free users.
and the model answers, optionally using ``web_search`` and an optionally
uploaded **read-only** document. We deliberately bypass the full SurfSense deep
agent stack (filesystem, file-intent, knowledge-base persistence, subagents,
skills, memory) because those middlewares stage or persist "documents" that an
anonymous session can never see again -- which produced phantom
"I saved it to a file" answers for free users.
For any other SurfSense capability (including web search) the model is
instructed (via the system prompt built here) to tell the user to create a
free account instead of pretending to perform the action.
For any other SurfSense capability the model is instructed (via the system
prompt built here) to tell the user to create a free account instead of
pretending to perform the action.
"""
from __future__ import annotations
@ -22,6 +22,7 @@ from deepagents.backends import StateBackend
from langchain.agents import create_agent
from langchain.agents.middleware import (
ModelCallLimitMiddleware,
ToolCallLimitMiddleware,
)
from langchain_core.language_models import BaseChatModel
from langgraph.types import Checkpointer
@ -31,6 +32,7 @@ from app.agents.chat.shared.middleware import (
RetryAfterMiddleware,
create_surfsense_compaction_middleware,
)
from app.agents.chat.shared.tools.web_search import create_web_search_tool
# Cap how much of an uploaded document we inline into the system prompt. The
# upload endpoint allows files up to several MB, but the doc is re-sent on
@ -41,12 +43,9 @@ _MAX_DOC_CHARS = 50_000
def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str:
"""Build the system prompt for the minimal anonymous chat agent.
The prompt keeps the assistant focused on plain Q/A from model knowledge,
inlines any uploaded document as read-only context, and treats the chat as
a registration funnel: every other SurfSense capability (scraping, live
data, deliverables, knowledge base, automations) redirects to sign-up, and
the assistant softly suggests an account when the conversation reveals a
competitive-intelligence need the platform serves.
The prompt keeps the assistant focused on plain Q/A + web search, inlines
any uploaded document as read-only context, and redirects every other
SurfSense feature to account registration.
"""
today = datetime.now(UTC).strftime("%A, %B %d, %Y")
@ -73,56 +72,30 @@ def build_anonymous_system_prompt(anon_doc: dict[str, Any] | None = None) -> str
return (
"You are SurfSense's free AI assistant, available to everyone without "
"login. SurfSense is the open-source competitive intelligence platform: "
"registered users get specialist agents that pull live market data from "
"Reddit, YouTube, Google Maps, Google Search, and the open web, turn it "
"into cited briefs, reports, podcasts, and presentations, keep findings "
"in a searchable knowledge base, and run scheduled monitoring "
"automations — plus a REST scraping API and MCP server for their own "
"agents.\n\n"
"login.\n\n"
f"Today's date is {today}.\n\n"
"## How to help\n"
"- Answer the user's questions directly and conversationally. You are "
"a straightforward question-and-answer assistant.\n"
"- Answer from your own knowledge. You do NOT have web access here, so "
"for current, real-time, or fast-changing facts (news, prices, "
"weather, recent events, live data) say you can't look them up in the "
"free experience and may be out of date.\n"
"- When a question needs current, real-time, or factual information "
"from the internet (news, prices, weather, recent events, live data), "
"use the `web_search` tool. Otherwise, answer directly from your own "
"knowledge.\n"
"- Be concise, accurate, and helpful. Use Markdown formatting when it "
"improves readability."
f"{doc_section}\n\n"
"## What is not available here\n"
"This is the free, no-login experience. You CANNOT search the web or "
"scrape any platform (Reddit, YouTube, Google Maps, Google Search, "
"websites), save files or notes, upload additional files, generate "
"reports, podcasts, resumes, presentations, or images, search or build "
"a knowledge base, connect to apps (Gmail, Google Drive, Notion, "
"Slack, Calendar, Discord, and similar), set up automations or "
"monitoring, use the scraping API or MCP server, or remember anything "
"across sessions.\n\n"
"This is the free, no-login experience. You CANNOT save files or "
"notes, generate reports, podcasts, resumes, presentations, or images, "
"search or build a knowledge base, connect to apps (Gmail, Google "
"Drive, Notion, Slack, Calendar, Discord, and similar), set up "
"automations, or remember anything across sessions.\n\n"
"If the user asks for any of these, do NOT pretend to do them and "
"never claim you saved, created, scraped, or stored anything. Instead, "
"briefly say that this needs a free SurfSense account and invite them "
"to sign up at https://www.surfsense.com/register — name the specific "
"capability that would solve their request (e.g. 'a SurfSense account "
"can scrape those Reddit threads live and turn them into a cited "
"brief'). Then offer to help with what you can do here (answering from "
"your own knowledge and about any uploaded document).\n\n"
"## Suggesting SurfSense when it genuinely fits\n"
"You are also the front door to SurfSense. When the conversation "
"reveals a need the full platform serves — researching competitors, "
"tracking pricing or rankings, monitoring brand mentions or reviews, "
"gauging Reddit/YouTube sentiment, generating leads, needing current "
"web data, or wanting recurring reports — first answer as well as you "
"can from your own knowledge, then add ONE short sentence pointing out "
"that a free SurfSense account can do that with live data, linking "
"https://www.surfsense.com/register.\n"
"- Be helpful first, never salesy: the answer is the product; the "
"suggestion is a footnote.\n"
"- At most one suggestion per response, and stop suggesting entirely "
"if the user declines or ignores it.\n"
"- Do not suggest it for needs SurfSense does not serve (casual chat, "
"coding help, homework, creative writing)."
"never claim you saved, created, or stored anything. Instead, briefly "
"let them know the feature requires a free SurfSense account and "
"invite them to create one at https://www.surfsense.com. Then offer to "
"help with what you can do here (answering questions and searching the "
"web)."
)
@ -132,13 +105,14 @@ async def create_anonymous_chat_agent(
checkpointer: Checkpointer,
anon_session_id: str | None = None,
anon_doc: dict[str, Any] | None = None,
enable_web_search: bool = True,
):
"""Create a minimal Q/A agent for anonymous / free chat.
Unlike :func:`create_surfsense_deep_agent`, this agent has no filesystem,
file-intent, knowledge-base persistence, subagent, skills, or memory
middleware -- and no tools at all. It answers purely from the model's own
knowledge; any uploaded document is injected into the system prompt as
middleware. Its only tool is ``web_search`` (when ``enable_web_search`` is
True), and any uploaded document is injected into the system prompt as
read-only context.
Args:
@ -146,23 +120,36 @@ async def create_anonymous_chat_agent(
checkpointer: LangGraph checkpointer for the ephemeral anon thread.
anon_session_id: Anonymous session id (used only for telemetry/metadata).
anon_doc: Optional ``{"title", "content"}`` for an uploaded document.
enable_web_search: When False, the agent runs as a pure LLM with no
tools (used when the user toggles web search off).
"""
tools = (
[create_web_search_tool(search_space_id=None, available_connectors=None)]
if enable_web_search
else []
)
# Reliability-only middleware. Nothing here touches the database or
# filesystem: the call limit guards against loops, compaction summarises
# long histories into in-graph state, and retry handles provider rate
# limits.
# filesystem: call limits guard against loops, compaction summarises long
# histories into in-graph state, and retry handles provider rate limits.
middleware: list[Any] = [
ModelCallLimitMiddleware(thread_limit=120, run_limit=80, exit_behavior="end"),
create_surfsense_compaction_middleware(llm, StateBackend),
RetryAfterMiddleware(max_retries=3),
]
if tools:
middleware.append(
ToolCallLimitMiddleware(
thread_limit=300, run_limit=80, exit_behavior="continue"
)
)
middleware.append(create_surfsense_compaction_middleware(llm, StateBackend))
middleware.append(RetryAfterMiddleware(max_retries=3))
system_prompt = build_anonymous_system_prompt(anon_doc)
agent = create_agent(
llm,
system_prompt=system_prompt,
tools=[],
tools=tools,
middleware=middleware,
context_schema=SurfSenseContextSchema,
checkpointer=checkpointer,

View file

@ -2,74 +2,43 @@
from __future__ import annotations
# Connected apps (hosted MCP + interim-native Gmail/Calendar) all route to the
# single ``mcp_discovery`` subagent. File connectors stay native (they enrich
# the knowledge base). Deprecated connectors (Discord/Teams/Luma) are omitted:
# they have no agent subagent, so their rows produce no tools.
CONNECTOR_TYPE_TO_CONNECTOR_AGENT_MAPS: dict[str, str] = {
"GOOGLE_GMAIL_CONNECTOR": "mcp_discovery",
"COMPOSIO_GMAIL_CONNECTOR": "mcp_discovery",
"GOOGLE_CALENDAR_CONNECTOR": "mcp_discovery",
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "mcp_discovery",
"LINEAR_CONNECTOR": "mcp_discovery",
"JIRA_CONNECTOR": "mcp_discovery",
"CLICKUP_CONNECTOR": "mcp_discovery",
"SLACK_CONNECTOR": "mcp_discovery",
"AIRTABLE_CONNECTOR": "mcp_discovery",
"NOTION_CONNECTOR": "mcp_discovery",
"CONFLUENCE_CONNECTOR": "mcp_discovery",
"MCP_CONNECTOR": "mcp_discovery",
"GOOGLE_GMAIL_CONNECTOR": "gmail",
"COMPOSIO_GMAIL_CONNECTOR": "gmail",
"GOOGLE_CALENDAR_CONNECTOR": "calendar",
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "calendar",
"DISCORD_CONNECTOR": "discord",
"TEAMS_CONNECTOR": "teams",
"LUMA_CONNECTOR": "luma",
"LINEAR_CONNECTOR": "linear",
"JIRA_CONNECTOR": "jira",
"CLICKUP_CONNECTOR": "clickup",
"SLACK_CONNECTOR": "slack",
"AIRTABLE_CONNECTOR": "airtable",
"NOTION_CONNECTOR": "notion",
"CONFLUENCE_CONNECTOR": "confluence",
"GOOGLE_DRIVE_CONNECTOR": "google_drive",
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "google_drive",
"DROPBOX_CONNECTOR": "dropbox",
"ONEDRIVE_CONNECTOR": "onedrive",
}
# ``mcp_discovery`` is gated any-of: present iff the workspace has at least one
# connected app. Tokens are searchable-type strings (Composio Gmail/Calendar
# map to the GOOGLE_* tokens in connector_searchable_types).
SUBAGENT_TO_REQUIRED_CONNECTOR_MAP: dict[str, frozenset[str]] = {
"deliverables": frozenset(),
"knowledge_base": frozenset(),
"web_crawler": frozenset(),
"youtube": frozenset(),
"google_maps": frozenset(),
"google_search": frozenset(),
"reddit": frozenset(),
"mcp_discovery": frozenset(
{
"SLACK_CONNECTOR",
"JIRA_CONNECTOR",
"LINEAR_CONNECTOR",
"CLICKUP_CONNECTOR",
"AIRTABLE_CONNECTOR",
"NOTION_CONNECTOR",
"CONFLUENCE_CONNECTOR",
"GOOGLE_GMAIL_CONNECTOR",
"GOOGLE_CALENDAR_CONNECTOR",
"MCP_CONNECTOR",
}
),
"airtable": frozenset({"AIRTABLE_CONNECTOR"}),
"calendar": frozenset({"GOOGLE_CALENDAR_CONNECTOR"}),
"clickup": frozenset({"CLICKUP_CONNECTOR"}),
"confluence": frozenset({"CONFLUENCE_CONNECTOR"}),
"discord": frozenset({"DISCORD_CONNECTOR"}),
"dropbox": frozenset({"DROPBOX_FILE"}),
"gmail": frozenset({"GOOGLE_GMAIL_CONNECTOR"}),
"google_drive": frozenset({"GOOGLE_DRIVE_FILE"}),
"jira": frozenset({"JIRA_CONNECTOR"}),
"linear": frozenset({"LINEAR_CONNECTOR"}),
"luma": frozenset({"LUMA_CONNECTOR"}),
"notion": frozenset({"NOTION_CONNECTOR"}),
"onedrive": frozenset({"ONEDRIVE_FILE"}),
}
# Old per-connector subagent names, kept working for checkpoint resume: a
# ``task(subagent_type="gmail")`` paused before the MCP consolidation resolves
# to the consolidated ``mcp_discovery`` subagent instead of hard-failing
# "subagent does not exist". New routing never emits these names.
LEGACY_SUBAGENT_ALIASES: dict[str, str] = {
"gmail": "mcp_discovery",
"calendar": "mcp_discovery",
"linear": "mcp_discovery",
"jira": "mcp_discovery",
"clickup": "mcp_discovery",
"slack": "mcp_discovery",
"airtable": "mcp_discovery",
"notion": "mcp_discovery",
"confluence": "mcp_discovery",
"discord": "mcp_discovery",
"teams": "mcp_discovery",
"luma": "mcp_discovery",
"slack": frozenset({"SLACK_CONNECTOR"}),
"teams": frozenset({"TEAMS_CONNECTOR"}),
}

View file

@ -31,7 +31,7 @@ def build_compiled_agent_graph_sync(
final_system_prompt: str,
backend_resolver: Any,
filesystem_mode: FilesystemMode,
workspace_id: int,
search_space_id: int,
user_id: str | None,
thread_id: int | None,
visibility: ChatVisibility,
@ -53,7 +53,7 @@ def build_compiled_agent_graph_sync(
tools=tools,
backend_resolver=backend_resolver,
filesystem_mode=filesystem_mode,
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
thread_id=thread_id,
visibility=visibility,

View file

@ -14,7 +14,7 @@ def build_action_log_mw(
*,
flags: AgentFeatureFlags,
thread_id: int | None,
workspace_id: int,
search_space_id: int,
user_id: str | None,
) -> ActionLogMiddleware | None:
if not enabled(flags, "enable_action_log") or thread_id is None:
@ -25,7 +25,7 @@ def build_action_log_mw(
# tool via ``ToolDefinition.reverse`` and can be wired here when used.
return ActionLogMiddleware(
thread_id=thread_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
)
except Exception: # pragma: no cover - defensive

View file

@ -84,7 +84,7 @@ class ActionLogMiddleware(AgentMiddleware):
Args:
thread_id: The current chat thread's primary-key id. Required to
persist a row; if ``None`` the middleware silently no-ops.
workspace_id: Workspace id for cascade-on-delete safety.
search_space_id: Search-space id for cascade-on-delete safety.
user_id: UUID string of the user driving this turn (nullable in
anonymous mode).
tool_definitions: Optional mapping of tool name -> :class:`ToolDefinition`
@ -98,13 +98,13 @@ class ActionLogMiddleware(AgentMiddleware):
self,
*,
thread_id: int | None,
workspace_id: int,
search_space_id: int,
user_id: str | None,
tool_definitions: dict[str, ToolDefinition] | None = None,
) -> None:
super().__init__()
self._thread_id = thread_id
self._workspace_id = workspace_id
self._search_space_id = search_space_id
self._user_id = user_id
self._tool_definitions = dict(tool_definitions or {})
@ -187,7 +187,7 @@ class ActionLogMiddleware(AgentMiddleware):
row = AgentActionLog(
thread_id=thread_id,
user_id=self._user_id,
workspace_id=self._workspace_id,
search_space_id=self._search_space_id,
# ``turn_id`` is the deprecated alias of ``tool_call_id``
# kept for one release for safe rollback. New consumers
# should read ``tool_call_id`` directly.

View file

@ -6,6 +6,8 @@ read-only). This middleware loads it once on the first turn into
* :class:`KnowledgeTreeMiddleware` can render the synthetic ``/documents``
view without touching the DB.
* :class:`KnowledgePriorityMiddleware` skips hybrid search and emits a
degenerate priority list.
* :class:`KBPostgresBackend` (``als_info`` / ``aread`` / ``_load_file_data``)
recognises the synthetic path.

View file

@ -40,7 +40,7 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
subagents: list[SubAgent | CompiledSubAgent],
system_prompt: str | None = TASK_SYSTEM_PROMPT,
task_description: str | None = None,
workspace_id: int | None = None,
search_space_id: int | None = None,
) -> None:
self._surf_checkpointer = checkpointer
super(SubAgentMiddleware, self).__init__()
@ -50,11 +50,11 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
)
self._backend = backend
self._subagents = subagents
# Workspace id is captured at build time (the orchestrator runs in
# exactly one workspace for its lifetime). The spawn-paused kill
# Search-space id is captured at build time (the orchestrator runs in
# exactly one search space for its lifetime). The spawn-paused kill
# switch keys on it so an operator can quarantine one workspace
# without affecting the rest of the deployment.
self._workspace_id = workspace_id
self._search_space_id = search_space_id
# Lazy subagent compilation. Compiling a subagent graph via
# ``create_agent`` is expensive (~250-400ms each) and there can be up
@ -75,7 +75,7 @@ class SurfSenseCheckpointedSubAgentMiddleware(SubAgentMiddleware):
task_tool = build_task_tool_with_parent_config(
descriptors,
task_description,
workspace_id=workspace_id,
search_space_id=search_space_id,
resolve_subagent=self._resolve_subagent,
)
if system_prompt and descriptors:

View file

@ -1,12 +1,12 @@
"""Per-workspace spawn-paused kill switch for the ``task`` boundary.
"""Per-search-space spawn-paused kill switch for the ``task`` boundary.
When operators see a runaway loop, a vendor outage, or a billing event
that requires immediate cessation of subagent traffic for a specific
workspace, they flip a Redis flag and the ``task`` tool short-circuits
without touching downstream services. The flag is **per-workspace**
without touching downstream services. The flag is **per-search-space**
so one tenant's incident never silences the rest of the deployment.
Flag key: ``surfsense:spawn_paused:{workspace_id}``
Flag key: ``surfsense:spawn_paused:{search_space_id}``
Flag value: any string-truthy value (we read presence, not contents).
TTL: set by whoever toggles the flag this module never expires
keys on its own, since "the flag is on" is itself the signal
@ -43,18 +43,18 @@ _DISABLED = os.environ.get(
}
def _flag_key(workspace_id: int) -> str:
return f"surfsense:spawn_paused:{workspace_id}"
def _flag_key(search_space_id: int) -> str:
return f"surfsense:spawn_paused:{search_space_id}"
async def is_spawn_paused(workspace_id: int | None) -> bool:
async def is_spawn_paused(search_space_id: int | None) -> bool:
"""Return ``True`` iff the workspace's spawn-paused flag is set in Redis.
A ``None`` workspace (e.g. dev paths that did not plumb the id
A ``None`` search-space (e.g. dev paths that did not plumb the id
through yet) bypasses the check. So does a Redis outage see module
docstring for the fail-open rationale.
"""
if _DISABLED or workspace_id is None:
if _DISABLED or search_space_id is None:
return False
try:
# Local import keeps the cold-path import cheap and lets routes
@ -63,7 +63,7 @@ async def is_spawn_paused(workspace_id: int | None) -> bool:
client = aioredis.from_url(config.REDIS_APP_URL, decode_responses=True)
try:
raw = await client.get(_flag_key(workspace_id))
raw = await client.get(_flag_key(search_space_id))
finally:
# ``aclose()`` is the async-safe variant on redis-py >=5; fall back
# to ``close()`` for older clients pinned in tests.
@ -74,8 +74,8 @@ async def is_spawn_paused(workspace_id: int | None) -> bool:
return bool(raw)
except Exception:
logger.warning(
"spawn_paused check failed for workspace_id=%s; failing open.",
workspace_id,
"spawn_paused check failed for search_space_id=%s; failing open.",
search_space_id,
exc_info=True,
)
return False

View file

@ -23,7 +23,6 @@ from langchain_core.tools import StructuredTool
from langgraph.errors import GraphInterrupt
from langgraph.types import Command, Interrupt
from app.agents.chat.multi_agent_chat.constants import LEGACY_SUBAGENT_ALIASES
from app.agents.chat.multi_agent_chat.subagents.shared.invocation import (
EXCLUDED_STATE_KEYS,
subagent_invoke_config,
@ -143,7 +142,7 @@ def build_task_tool_with_parent_config(
subagents: list[dict[str, Any]],
task_description: str | None = None,
*,
workspace_id: int | None = None,
search_space_id: int | None = None,
resolve_subagent: Callable[[str], Runnable] | None = None,
) -> BaseTool:
"""Upstream ``_build_task_tool`` + parent ``runtime.config`` propagation + resume bridging.
@ -158,26 +157,6 @@ def build_task_tool_with_parent_config(
case a trivial dict-backed resolver is used.
"""
subagent_names: set[str] = {spec["name"] for spec in subagents}
def _canonical_subagent_type(subagent_type: str) -> str:
"""Resolve a legacy connector subagent name to its consolidated route.
Only rewrites when the requested name is unavailable but its alias is
(checkpoint resume of a pre-consolidation ``task(...)`` call). Current
routing never emits legacy names, so live traffic is untouched.
"""
if subagent_type in subagent_names:
return subagent_type
alias = LEGACY_SUBAGENT_ALIASES.get(subagent_type)
if alias is not None and alias in subagent_names:
logger.info(
"[hitl_route] aliasing legacy subagent %r -> %r",
subagent_type,
alias,
)
return alias
return subagent_type
if resolve_subagent is None:
_eager_graphs: dict[str, Runnable] = {
spec["name"]: spec["runnable"] for spec in subagents if "runnable" in spec
@ -364,28 +343,6 @@ def build_task_tool_with_parent_config(
cleaned = hint.strip()
return cleaned or None
def _forward_mention_pins(subagent_state: dict, runtime: ToolRuntime) -> None:
"""Carry the turn's ``@``-mention pins from main context into subagent state.
Subagents are compiled without a ``context_schema`` and invoked without
``context=``, so ``runtime.context`` (which holds the ``@``-mentioned
document/folder ids) does not reach them. The ``task`` tool runs in the
main runtime, which *does* have the context, so we copy the pins into the
forwarded state where ``search_knowledge_base`` reads them. Only set keys
when present so we never clobber pins already on state (e.g. nested
``ask_knowledge_base`` re-entry).
"""
ctx = getattr(runtime, "context", None)
if ctx is None:
return
for state_key, ctx_attr in (
("mentioned_document_ids", "mentioned_document_ids"),
("mentioned_folder_ids", "mentioned_folder_ids"),
):
value = getattr(ctx, ctx_attr, None)
if value:
subagent_state[state_key] = list(value)
def _validate_and_prepare_state(
subagent_type: str, description: str, runtime: ToolRuntime
) -> tuple[Runnable, dict]:
@ -393,7 +350,6 @@ def build_task_tool_with_parent_config(
subagent_state = {
k: v for k, v in runtime.state.items() if k not in EXCLUDED_STATE_KEYS
}
_forward_mention_pins(subagent_state, runtime)
hint = _resolve_context_hint(subagent_type, description, runtime)
if hint:
# Tagged block so the subagent prompt can pattern-match the section.
@ -503,7 +459,6 @@ def build_task_tool_with_parent_config(
batched HITL is intentionally out of scope.
"""
async with semaphore:
subagent_type = _canonical_subagent_type(subagent_type)
if subagent_type not in subagent_names:
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
return (
@ -680,7 +635,6 @@ def build_task_tool_with_parent_config(
"task: must provide either single-mode (`description`+`subagent_type`) "
"or batch-mode (`tasks`)."
)
subagent_type = _canonical_subagent_type(subagent_type)
if subagent_type not in subagent_names:
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
return (
@ -852,10 +806,10 @@ def build_task_tool_with_parent_config(
atask_start = time.perf_counter()
# Ops kill switch: short-circuit every task() call for this workspace
# so the orchestrator stops hammering downstream APIs.
if await is_spawn_paused(workspace_id):
if await is_spawn_paused(search_space_id):
logger.warning(
"[hitl_route] atask SPAWN_PAUSED: workspace_id=%s tool_call_id=%s",
workspace_id,
"[hitl_route] atask SPAWN_PAUSED: search_space_id=%s tool_call_id=%s",
search_space_id,
runtime.tool_call_id,
)
return (
@ -890,7 +844,6 @@ def build_task_tool_with_parent_config(
subagent_type,
runtime.tool_call_id,
)
subagent_type = _canonical_subagent_type(subagent_type)
if subagent_type not in subagent_names:
allowed_types = ", ".join([f"`{k}`" for k in subagent_names])
return (

View file

@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from langchain_core.tools import BaseTool
@ -24,7 +25,7 @@ def build_context_editing_mw(
flags: AgentFeatureFlags,
max_input_tokens: int | None,
tools: Sequence[BaseTool],
workspace_id: int | None = None,
backend_resolver: Any,
) -> SpillingContextEditingMiddleware | None:
if not enabled(flags, "enable_context_editing") or not max_input_tokens:
return None
@ -45,5 +46,5 @@ def build_context_editing_mw(
)
return SpillingContextEditingMiddleware(
edits=[spill_edit, clear_edit],
workspace_id=workspace_id,
backend_resolver=backend_resolver,
)

View file

@ -4,31 +4,25 @@ SpillToBackendEdit + SpillingContextEditingMiddleware.
LangChain's :class:`ClearToolUsesEdit` discards old ``ToolMessage.content``
when the context-editing budget triggers, replacing the body with a fixed
placeholder. That's lossy: anything the agent might want to revisit is
gone. The spill pattern (originally from OpenCode's
gone. The spill-to-disk pattern (originally from OpenCode's
``opencode/packages/opencode/src/tool/truncate.ts``) keeps the prune
behavior but persists the full original payload first to the
``tool_output_spills`` table and upgrades the placeholder to a
``spill_<uuid>`` reference the agent can read back with the shared
``read_run``/``search_run`` tools on demand.
Why the DB and not the runtime filesystem backend: the previous version
wrote via ``backend.aupload_files``, which is a no-op on cloud
(``KBPostgresBackend`` raises ``NotImplementedError``) and mismatched on
desktop (paths must be ``/{mount_id}/...``), so spills were unrecoverable
in production. A table works on every deployment and needs no sandbox.
behavior but writes the full original payload to the runtime backend
under ``/tool_outputs/{thread_id}/{message_id}.txt`` first. The
placeholder is then upgraded to point at the spill path so the agent
(or a subagent) can read it back on demand.
Why this is a middleware subclass instead of a plain ``ContextEdit``:
``ContextEdit.apply`` is sync, but the DB write is async. We generate the
spill id and capture the payload inside ``apply`` (so the placeholder can
reference the final id immediately) and flush the rows from
``awrap_model_call`` *before* delegating to the handler.
``ContextEdit.apply`` is sync, but writing to the backend is async. We
capture the spill payloads inside ``apply`` and flush them via
``await backend.aupload_files(...)`` from ``awrap_model_call`` *before*
delegating to the handler, so the explore subagent can always read what
the placeholder advertises.
"""
from __future__ import annotations
import logging
import threading
import uuid
from collections.abc import Awaitable, Callable, Sequence
from copy import deepcopy
from dataclasses import dataclass, field
@ -50,6 +44,7 @@ from langchain_core.messages.utils import count_tokens_approximately
from langgraph.config import get_config
if TYPE_CHECKING:
from deepagents.backends.protocol import BackendProtocol
from langchain.agents.middleware.types import (
ModelRequest,
ModelResponse,
@ -57,27 +52,24 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Namespace for deterministic spill ids: the same (thread, message) always maps
# to the same row, so re-running the edit on later model calls (edits apply to a
# per-call copy of the messages, never to persisted state) re-references the
# existing spill instead of inserting a duplicate every turn.
_SPILL_NAMESPACE = uuid.UUID("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
DEFAULT_SPILL_PREFIX = "/tool_outputs"
def _spill_id_for(thread_id: str | None, message_key: str) -> uuid.UUID:
return uuid.uuid5(_SPILL_NAMESPACE, f"{thread_id or 'no_thread'}:{message_key}")
def _build_spill_placeholder(spill_id: uuid.UUID) -> str:
def _build_spill_placeholder(spill_path: str) -> str:
"""Build the user-facing placeholder text shown to the model."""
return (
f"[cleared — full output stored as spill_{spill_id}; "
"use read_run/search_run to read it]"
f"[cleared — full output at {spill_path}; ask the explore subagent to read it]"
)
def _get_thread_id() -> str | None:
"""Best-effort ``configurable.thread_id`` for the spill row (``None`` if absent)."""
def _get_thread_id_or_session() -> str:
"""Best-effort thread_id discovery for the spill path.
Falls back to a process-stable string if no LangGraph config is
available (e.g. unit tests). The exact value doesn't matter as long
as it's stable within one stream so the placeholder paths line up
with the actual upload path.
"""
try:
config = get_config()
thread_id = config.get("configurable", {}).get("thread_id")
@ -85,18 +77,17 @@ def _get_thread_id() -> str | None:
return str(thread_id)
except RuntimeError:
pass
return None
return "no_thread"
@dataclass(slots=True)
class SpillToBackendEdit(ContextEdit):
"""Capture-and-replace context edit that spills full tool output to the DB.
"""Capture-and-replace context edit that spills full tool output to the backend.
Behaves like :class:`ClearToolUsesEdit` (same trigger / keep / exclude
semantics) **and** records the original ``ToolMessage.content`` in
:attr:`pending_spills` so the wrapping middleware can flush the rows to
``tool_output_spills`` before the model call. The spill id is generated up
front so the placeholder can reference it immediately.
:attr:`pending_spills` so the wrapping middleware can flush them
before the model call.
Args:
trigger: Token threshold above which the edit fires.
@ -106,6 +97,8 @@ class SpillToBackendEdit(ContextEdit):
exclude_tools: Names of tools whose output is NOT spilled.
clear_tool_inputs: Also clear the originating ``AIMessage.tool_calls``
args when their pair is cleared.
path_prefix: Path under the backend where spills are written.
Default ``"/tool_outputs"``.
"""
trigger: int = 100_000
@ -113,16 +106,12 @@ class SpillToBackendEdit(ContextEdit):
keep: int = 3
clear_tool_inputs: bool = False
exclude_tools: Sequence[str] = ()
path_prefix: str = DEFAULT_SPILL_PREFIX
# (spill_id, content_bytes, tool_name, thread_id)
pending_spills: list[tuple[uuid.UUID, bytes, str | None, str | None]] = field(
default_factory=list
)
pending_spills: list[tuple[str, bytes]] = field(default_factory=list)
_lock: threading.Lock = field(default_factory=threading.Lock)
def drain_pending(
self,
) -> list[tuple[uuid.UUID, bytes, str | None, str | None]]:
def drain_pending(self) -> list[tuple[str, bytes]]:
"""Return and clear the pending-spill list atomically."""
with self._lock:
out = list(self.pending_spills)
@ -150,7 +139,7 @@ class SpillToBackendEdit(ContextEdit):
if self.keep:
candidates = candidates[: -self.keep]
thread_id = _get_thread_id()
thread_id = _get_thread_id_or_session()
excluded_tools = set(self.exclude_tools)
for idx, tool_message in candidates:
@ -179,23 +168,24 @@ class SpillToBackendEdit(ContextEdit):
if tool_name in excluded_tools:
continue
message_key = tool_message.id or tool_message.tool_call_id or "unknown"
spill_id = _spill_id_for(thread_id, message_key)
message_id = tool_message.id or tool_message.tool_call_id or "unknown"
spill_path = f"{self.path_prefix}/{thread_id}/{message_id}.txt"
original = tool_message.content
payload = self._encode_payload(original)
with self._lock:
self.pending_spills.append((spill_id, payload, tool_name, thread_id))
self.pending_spills.append((spill_path, payload))
messages[idx] = tool_message.model_copy(
update={
"artifact": None,
"content": _build_spill_placeholder(spill_id),
"content": _build_spill_placeholder(spill_path),
"response_metadata": {
**tool_message.response_metadata,
"context_editing": {
"cleared": True,
"strategy": "spill_to_db",
"spill_id": str(spill_id),
"strategy": "spill_to_backend",
"spill_path": spill_path,
},
},
}
@ -253,30 +243,52 @@ class SpillToBackendEdit(ContextEdit):
)
BackendResolver = "Callable[[Any], BackendProtocol] | BackendProtocol"
class SpillingContextEditingMiddleware(ContextEditingMiddleware):
""":class:`ContextEditingMiddleware` that flushes :class:`SpillToBackendEdit` writes.
Runs the configured edits as the parent does, then persists any pending
spills to ``tool_output_spills`` before delegating to the model handler.
Spill failures are logged but never abort the model call the placeholder
text is already in the message, so the worst case is the agent gets a
placeholder it cannot follow up on.
Runs the configured edits as the parent does, then flushes any
pending spills via the supplied backend resolver before delegating
to the model handler. Spill failures are logged but never abort the
model call the placeholder text is already in the message, so the
worst case is the agent gets a placeholder it cannot follow up on.
"""
def __init__(
self,
*,
edits: Sequence[ContextEdit],
workspace_id: int | None = None,
backend_resolver: BackendResolver | None = None,
token_count_method: str = "approximate",
) -> None:
super().__init__(edits=list(edits), token_count_method=token_count_method) # type: ignore[arg-type]
self._workspace_id = workspace_id
self._backend_resolver = backend_resolver
def _collect_pending(
self,
) -> list[tuple[uuid.UUID, bytes, str | None, str | None]]:
out: list[tuple[uuid.UUID, bytes, str | None, str | None]] = []
def _resolve_backend(self, request: ModelRequest) -> BackendProtocol | None:
if self._backend_resolver is None:
return None
if callable(self._backend_resolver):
try:
from langchain.tools import ToolRuntime
tool_runtime = ToolRuntime(
state=getattr(request, "state", {}),
context=getattr(request.runtime, "context", None),
stream_writer=getattr(request.runtime, "stream_writer", None),
store=getattr(request.runtime, "store", None),
config=getattr(request.runtime, "config", None) or {},
tool_call_id=None,
)
return self._backend_resolver(tool_runtime)
except Exception:
logger.exception("Failed to resolve spill backend")
return None
return self._backend_resolver # type: ignore[return-value]
def _collect_pending(self) -> list[tuple[str, bytes]]:
out: list[tuple[str, bytes]] = []
for edit in self.edits:
if isinstance(edit, SpillToBackendEdit):
out.extend(edit.drain_pending())
@ -309,37 +321,28 @@ class SpillingContextEditingMiddleware(ContextEditingMiddleware):
pending = self._collect_pending()
if pending:
await self._flush_spills(pending)
return await handler(request.override(messages=edited_messages))
async def _flush_spills(
self, pending: list[tuple[uuid.UUID, bytes, str | None, str | None]]
) -> None:
"""Persist spilled tool outputs to the DB (best-effort)."""
from app.capabilities.core.runs import record_spill
from app.db import async_session_maker
backend = self._resolve_backend(request)
if backend is not None:
try:
async with async_session_maker() as session:
for spill_id, payload, tool_name, thread_id in pending:
await record_spill(
session,
spill_id=spill_id,
content=payload.decode("utf-8", errors="replace"),
workspace_id=self._workspace_id,
thread_id=thread_id,
tool_name=tool_name,
)
await backend.aupload_files(pending)
except Exception:
logger.exception(
"Spill-to-DB flush failed (%d rows); placeholders remain in "
"messages but content is unrecoverable",
"Spill-to-backend upload failed (%d files); placeholders "
"remain in messages but content is unrecoverable",
len(pending),
)
else:
logger.warning(
"SpillToBackendEdit produced %d pending spills but no backend "
"resolver was configured; content is unrecoverable",
len(pending),
)
return await handler(request.override(messages=edited_messages))
__all__ = [
"DEFAULT_SPILL_PREFIX",
"ClearToolUsesEdit",
"SpillToBackendEdit",
"SpillingContextEditingMiddleware",

View file

@ -12,14 +12,14 @@ from .middleware import (
def build_kb_persistence_mw(
*,
filesystem_mode: FilesystemMode,
workspace_id: int,
search_space_id: int,
user_id: str | None,
thread_id: int | None,
) -> KnowledgeBasePersistenceMiddleware | None:
if filesystem_mode != FilesystemMode.CLOUD:
return None
return KnowledgeBasePersistenceMiddleware(
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_id=user_id,
filesystem_mode=filesystem_mode,
thread_id=thread_id,

View file

@ -83,11 +83,11 @@ def _basename(path: str) -> str:
async def _ensure_folder_hierarchy(
session: AsyncSession,
*,
workspace_id: int,
search_space_id: int,
created_by_id: str | None,
folder_parts: list[str],
) -> int | None:
"""Ensure a chain of folder names exists under the workspace.
"""Ensure a chain of folder names exists under the search space.
Returns the leaf folder id, or ``None`` if ``folder_parts`` is empty
(i.e. a document directly under ``/documents/``).
@ -98,7 +98,7 @@ async def _ensure_folder_hierarchy(
for raw in folder_parts:
name = safe_folder_segment(str(raw))
query = select(Folder).where(
Folder.workspace_id == workspace_id,
Folder.search_space_id == search_space_id,
Folder.name == name,
)
if parent_id is None:
@ -111,7 +111,9 @@ async def _ensure_folder_hierarchy(
sibling_query = (
select(Folder.position).order_by(Folder.position.desc()).limit(1)
)
sibling_query = sibling_query.where(Folder.workspace_id == workspace_id)
sibling_query = sibling_query.where(
Folder.search_space_id == search_space_id
)
if parent_id is None:
sibling_query = sibling_query.where(Folder.parent_id.is_(None))
else:
@ -122,7 +124,7 @@ async def _ensure_folder_hierarchy(
name=name,
position=generate_key_between(last_position, None),
parent_id=parent_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_id=created_by_id,
updated_at=datetime.now(UTC),
)
@ -135,7 +137,7 @@ async def _ensure_folder_hierarchy(
async def _resolve_folder_id(
session: AsyncSession,
*,
workspace_id: int,
search_space_id: int,
folder_parts: list[str],
) -> int | None:
"""Look up an existing folder chain without creating anything.
@ -149,7 +151,7 @@ async def _resolve_folder_id(
for raw in folder_parts:
name = safe_folder_segment(str(raw))
query = select(Folder).where(
Folder.workspace_id == workspace_id,
Folder.search_space_id == search_space_id,
Folder.name == name,
)
query = (
@ -183,7 +185,7 @@ async def _create_document(
*,
virtual_path: str,
content: str,
workspace_id: int,
search_space_id: int,
created_by_id: str | None,
) -> Document:
"""Create a NOTE Document + Chunks for ``virtual_path``."""
@ -192,21 +194,21 @@ async def _create_document(
raise ValueError(f"invalid /documents path '{virtual_path}'")
folder_id = await _ensure_folder_hierarchy(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_id=created_by_id,
folder_parts=folder_parts,
)
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.NOTE,
virtual_path,
workspace_id,
search_space_id,
)
# Pre-check the path-derived unique_identifier_hash so a duplicate path
# surfaces as a clean ValueError instead of an INSERT IntegrityError that
# poisons the session. Content is intentionally not unique (cp a b).
path_collision = await session.execute(
select(Document.id).where(
Document.workspace_id == workspace_id,
Document.search_space_id == search_space_id,
Document.unique_identifier_hash == unique_identifier_hash,
)
)
@ -215,7 +217,7 @@ async def _create_document(
f"a document already exists at path '{virtual_path}' "
"(unique_identifier_hash collision)"
)
content_hash = generate_content_hash(content, workspace_id)
content_hash = generate_content_hash(content, search_space_id)
doc = Document(
title=title,
document_type=DocumentType.NOTE,
@ -224,7 +226,7 @@ async def _create_document(
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
source_markdown=content,
workspace_id=workspace_id,
search_space_id=search_space_id,
folder_id=folder_id,
created_by_id=created_by_id,
updated_at=datetime.now(UTC),
@ -239,15 +241,8 @@ async def _create_document(
chunk_embeddings = await asyncio.to_thread(embed_texts, chunks)
session.add_all(
[
Chunk(
document_id=doc.id,
content=text,
embedding=embedding,
position=i,
)
for i, (text, embedding) in enumerate(
zip(chunks, chunk_embeddings, strict=True)
)
Chunk(document_id=doc.id, content=text, embedding=embedding)
for text, embedding in zip(chunks, chunk_embeddings, strict=True)
]
)
return doc
@ -259,13 +254,13 @@ async def _update_document(
doc_id: int,
content: str,
virtual_path: str,
workspace_id: int,
search_space_id: int,
) -> Document | None:
"""Update an existing Document's content + chunks."""
result = await session.execute(
select(Document).where(
Document.id == doc_id,
Document.workspace_id == workspace_id,
Document.search_space_id == search_space_id,
)
)
document = result.scalar_one_or_none()
@ -274,7 +269,7 @@ async def _update_document(
document.content = content
document.source_markdown = content
document.content_hash = generate_content_hash(content, workspace_id)
document.content_hash = generate_content_hash(content, search_space_id)
document.updated_at = datetime.now(UTC)
metadata = dict(document.document_metadata or {})
metadata["virtual_path"] = virtual_path
@ -282,7 +277,7 @@ async def _update_document(
document.unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.NOTE,
virtual_path,
workspace_id,
search_space_id,
)
summary_embedding = (await asyncio.to_thread(embed_texts, [content]))[0]
@ -294,15 +289,8 @@ async def _update_document(
chunk_embeddings = await asyncio.to_thread(embed_texts, chunks)
session.add_all(
[
Chunk(
document_id=document.id,
content=text,
embedding=embedding,
position=i,
)
for i, (text, embedding) in enumerate(
zip(chunks, chunk_embeddings, strict=True)
)
Chunk(document_id=document.id, content=text, embedding=embedding)
for text, embedding in zip(chunks, chunk_embeddings, strict=True)
]
)
return document
@ -316,7 +304,7 @@ async def _update_document(
async def _apply_move(
session: AsyncSession,
*,
workspace_id: int,
search_space_id: int,
created_by_id: str | None,
move: dict[str, Any],
doc_id_by_path: dict[str, int],
@ -339,14 +327,14 @@ async def _apply_move(
result = await session.execute(
select(Document).where(
Document.id == doc_id,
Document.workspace_id == workspace_id,
Document.search_space_id == search_space_id,
)
)
document = result.scalar_one_or_none()
if document is None:
document = await virtual_path_to_doc(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
virtual_path=source,
)
if document is None:
@ -362,7 +350,7 @@ async def _apply_move(
return None
folder_id = await _ensure_folder_hierarchy(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_id=created_by_id,
folder_parts=folder_parts,
)
@ -375,7 +363,7 @@ async def _apply_move(
document.unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.NOTE,
dest,
workspace_id,
search_space_id,
)
document.updated_at = datetime.now(UTC)
@ -487,9 +475,7 @@ async def _load_chunks_for_snapshot(
session: AsyncSession, *, doc_id: int
) -> list[dict[str, str]]:
rows = await session.execute(
select(Chunk.content)
.where(Chunk.document_id == doc_id)
.order_by(Chunk.position, Chunk.id)
select(Chunk.content).where(Chunk.document_id == doc_id).order_by(Chunk.id)
)
return [{"content": row.content} for row in rows.all() if row.content is not None]
@ -499,7 +485,7 @@ async def _snapshot_document_pre_write(
*,
doc: Document,
action_id: int | None,
workspace_id: int,
search_space_id: int,
turn_id: str | None = None,
deferred_dispatches: list[int] | None = None,
) -> int | None:
@ -515,7 +501,7 @@ async def _snapshot_document_pre_write(
payload = _doc_revision_payload(doc, chunks_before=chunks)
rev = DocumentRevision(
document_id=doc.id,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_turn_id=turn_id,
agent_action_id=action_id,
**payload,
@ -542,7 +528,7 @@ async def _snapshot_document_pre_create(
session: AsyncSession,
*,
action_id: int | None,
workspace_id: int,
search_space_id: int,
turn_id: str | None = None,
deferred_dispatches: list[int] | None = None,
) -> int | None:
@ -556,7 +542,7 @@ async def _snapshot_document_pre_create(
async with session.begin_nested():
rev = DocumentRevision(
document_id=None,
workspace_id=workspace_id,
search_space_id=search_space_id,
content_before=None,
title_before=None,
folder_id_before=None,
@ -584,7 +570,7 @@ async def _snapshot_document_pre_move(
*,
doc: Document,
action_id: int | None,
workspace_id: int,
search_space_id: int,
turn_id: str | None = None,
deferred_dispatches: list[int] | None = None,
) -> int | None:
@ -594,7 +580,7 @@ async def _snapshot_document_pre_move(
payload = _doc_revision_payload(doc, chunks_before=None)
rev = DocumentRevision(
document_id=doc.id,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_turn_id=turn_id,
agent_action_id=action_id,
**payload,
@ -622,7 +608,7 @@ async def _snapshot_folder_pre_mkdir(
*,
folder: Folder,
action_id: int | None,
workspace_id: int,
search_space_id: int,
turn_id: str | None = None,
deferred_dispatches: list[int] | None = None,
) -> int | None:
@ -635,7 +621,7 @@ async def _snapshot_folder_pre_mkdir(
async with session.begin_nested():
rev = FolderRevision(
folder_id=folder.id,
workspace_id=workspace_id,
search_space_id=search_space_id,
name_before=None,
parent_id_before=None,
position_before=None,
@ -668,7 +654,7 @@ async def _snapshot_folder_pre_mkdir(
async def commit_staged_filesystem_state(
state: dict[str, Any] | AgentState,
*,
workspace_id: int,
search_space_id: int,
created_by_id: str | None,
filesystem_mode: FilesystemMode = FilesystemMode.CLOUD,
thread_id: int | None = None,
@ -812,7 +798,7 @@ async def commit_staged_filesystem_state(
continue
folder_id = await _ensure_folder_hierarchy(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_id=created_by_id,
folder_parts=folder_parts_full,
)
@ -831,7 +817,7 @@ async def commit_staged_filesystem_state(
session,
folder=folder_row,
action_id=action_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
turn_id=tcid,
deferred_dispatches=deferred_dispatches,
)
@ -849,14 +835,14 @@ async def commit_staged_filesystem_state(
res_pre = await session.execute(
select(Document).where(
Document.id == doc_id_pre,
Document.workspace_id == workspace_id,
Document.search_space_id == search_space_id,
)
)
document_pre = res_pre.scalar_one_or_none()
if document_pre is None:
document_pre = await virtual_path_to_doc(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
virtual_path=source,
)
if document_pre is not None:
@ -864,14 +850,14 @@ async def commit_staged_filesystem_state(
session,
doc=document_pre,
action_id=action_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
turn_id=tcid,
deferred_dispatches=deferred_dispatches,
)
applied = await _apply_move(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_id=created_by_id,
move=move,
doc_id_by_path=doc_id_by_path,
@ -935,7 +921,7 @@ async def commit_staged_filesystem_state(
# INSERT (which would hit the path-derived unique hash).
existing = await virtual_path_to_doc(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
virtual_path=path,
)
if existing is not None:
@ -946,7 +932,7 @@ async def commit_staged_filesystem_state(
result_doc = await session.execute(
select(Document).where(
Document.id == doc_id,
Document.workspace_id == workspace_id,
Document.search_space_id == search_space_id,
)
)
existing_doc = result_doc.scalar_one_or_none()
@ -955,7 +941,7 @@ async def commit_staged_filesystem_state(
session,
doc=existing_doc,
action_id=action_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
turn_id=tcid,
deferred_dispatches=deferred_dispatches,
)
@ -964,7 +950,7 @@ async def commit_staged_filesystem_state(
doc_id=doc_id,
content=content,
virtual_path=path,
workspace_id=workspace_id,
search_space_id=search_space_id,
)
if updated is not None:
committed_updates.append(
@ -972,7 +958,7 @@ async def commit_staged_filesystem_state(
"id": updated.id,
"title": updated.title,
"documentType": DocumentType.NOTE.value,
"workspaceId": workspace_id,
"searchSpaceId": search_space_id,
"folderId": updated.folder_id,
"createdById": str(created_by_id)
if created_by_id
@ -989,7 +975,7 @@ async def commit_staged_filesystem_state(
placeholder_revision_id = await _snapshot_document_pre_create(
session,
action_id=action_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
turn_id=tcid,
deferred_dispatches=deferred_dispatches,
)
@ -999,7 +985,7 @@ async def commit_staged_filesystem_state(
session,
virtual_path=path,
content=content,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_id=created_by_id,
)
except ValueError as exc:
@ -1043,7 +1029,7 @@ async def commit_staged_filesystem_state(
"id": new_doc.id,
"title": new_doc.title,
"documentType": DocumentType.NOTE.value,
"workspaceId": workspace_id,
"searchSpaceId": search_space_id,
"folderId": new_doc.folder_id,
"createdById": str(created_by_id)
if created_by_id
@ -1067,14 +1053,14 @@ async def commit_staged_filesystem_state(
result = await session.execute(
select(Document).where(
Document.id == doc_id_for_delete,
Document.workspace_id == workspace_id,
Document.search_space_id == search_space_id,
)
)
document_to_delete = result.scalar_one_or_none()
if document_to_delete is None:
document_to_delete = await virtual_path_to_doc(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
virtual_path=final,
)
if document_to_delete is None:
@ -1098,7 +1084,7 @@ async def commit_staged_filesystem_state(
)
rev = DocumentRevision(
document_id=doc_pk,
workspace_id=workspace_id,
search_space_id=search_space_id,
created_by_turn_id=tcid,
agent_action_id=action_id,
**payload,
@ -1128,7 +1114,7 @@ async def commit_staged_filesystem_state(
"id": doc_pk,
"title": doc_title,
"documentType": DocumentType.NOTE.value,
"workspaceId": workspace_id,
"searchSpaceId": search_space_id,
"folderId": doc_folder_id,
"createdById": str(created_by_id) if created_by_id else None,
"virtualPath": final,
@ -1149,7 +1135,7 @@ async def commit_staged_filesystem_state(
continue
folder_id = await _resolve_folder_id(
session,
workspace_id=workspace_id,
search_space_id=search_space_id,
folder_parts=folder_parts,
)
if folder_id is None:
@ -1161,7 +1147,7 @@ async def commit_staged_filesystem_state(
docs_in_folder = await session.execute(
select(Document.id)
.where(Document.folder_id == folder_id)
.where(Document.workspace_id == workspace_id)
.where(Document.search_space_id == search_space_id)
.limit(1)
)
if docs_in_folder.scalar_one_or_none() is not None:
@ -1173,7 +1159,7 @@ async def commit_staged_filesystem_state(
child_folders = await session.execute(
select(Folder.id)
.where(Folder.parent_id == folder_id)
.where(Folder.workspace_id == workspace_id)
.where(Folder.search_space_id == search_space_id)
.limit(1)
)
if child_folders.scalar_one_or_none() is not None:
@ -1201,7 +1187,7 @@ async def commit_staged_filesystem_state(
if snapshot_enabled and action_id is not None:
rev = FolderRevision(
folder_id=folder_pk,
workspace_id=workspace_id,
search_space_id=search_space_id,
name_before=folder_name,
parent_id_before=folder_parent_id,
position_before=folder_position,
@ -1230,7 +1216,7 @@ async def commit_staged_filesystem_state(
{
"id": folder_pk,
"name": folder_name,
"workspaceId": workspace_id,
"searchSpaceId": search_space_id,
"parentId": folder_parent_id,
"virtualPath": final,
}
@ -1239,7 +1225,9 @@ async def commit_staged_filesystem_state(
await session.commit()
except Exception: # pragma: no cover - rollback safety net
logger.exception("kb_persistence: commit failed (workspace=%s)", workspace_id)
logger.exception(
"kb_persistence: commit failed (search_space=%s)", search_space_id
)
# Outer commit raised: everything above rolled back, so drop the
# deferred dispatches.
deferred_dispatches.clear()
@ -1398,9 +1386,9 @@ async def commit_staged_filesystem_state(
_ = turn_id_for_revision # diagnostic-only; silence unused lint
logger.info(
"kb_persistence: commit (workspace=%s) creates=%d updates=%d "
"kb_persistence: commit (search_space=%s) creates=%d updates=%d "
"moves=%d staged_dirs=%d deletes=%d folder_deletes=%d discarded=%d",
workspace_id,
search_space_id,
len(committed_creates),
len(committed_updates),
len(applied_moves),
@ -1426,12 +1414,12 @@ class KnowledgeBasePersistenceMiddleware(AgentMiddleware): # type: ignore[type-
def __init__(
self,
*,
workspace_id: int,
search_space_id: int,
created_by_id: str | None,
filesystem_mode: FilesystemMode,
thread_id: int | None = None,
) -> None:
self.workspace_id = workspace_id
self.search_space_id = search_space_id
self.created_by_id = created_by_id
self.filesystem_mode = filesystem_mode
self.thread_id = thread_id
@ -1446,7 +1434,7 @@ class KnowledgeBasePersistenceMiddleware(AgentMiddleware): # type: ignore[type-
return None
return await commit_staged_filesystem_state(
state,
workspace_id=self.workspace_id,
search_space_id=self.search_space_id,
created_by_id=self.created_by_id,
filesystem_mode=self.filesystem_mode,
thread_id=self._resolve_thread_id(),

View file

@ -0,0 +1,42 @@
"""KB priority planner: <priority_documents> injection."""
from __future__ import annotations
from langchain_core.language_models import BaseChatModel
from app.agents.chat.multi_agent_chat.shared.filesystem_selection import FilesystemMode
from app.agents.chat.multi_agent_chat.shared.middleware.knowledge_search import (
KnowledgePriorityMiddleware,
)
from app.services.llm_service import get_planner_llm
def build_knowledge_priority_mw(
*,
llm: BaseChatModel,
search_space_id: int,
filesystem_mode: FilesystemMode,
available_connectors: list[str] | None,
available_document_types: list[str] | None,
mentioned_document_ids: list[int] | None,
preinjection_enabled: bool = True,
) -> KnowledgePriorityMiddleware:
"""Build the KB priority middleware.
When ``preinjection_enabled`` is False (the lazy default), the middleware
runs in mentions-only mode: it skips the expensive planner LLM + embedding
+ hybrid search and only surfaces explicit @-mentions. The main agent is
expected to pull relevant KB content on demand via the
``search_knowledge_base`` tool instead.
"""
return KnowledgePriorityMiddleware(
llm=llm,
planner_llm=get_planner_llm(),
search_space_id=search_space_id,
filesystem_mode=filesystem_mode,
available_connectors=available_connectors,
available_document_types=available_document_types,
mentioned_document_ids=mentioned_document_ids,
inject_system_message=False,
mentions_only=not preinjection_enabled,
)

View file

@ -12,13 +12,13 @@ from .middleware import KnowledgeTreeMiddleware
def build_knowledge_tree_mw(
*,
filesystem_mode: FilesystemMode,
workspace_id: int,
search_space_id: int,
llm: BaseChatModel,
) -> KnowledgeTreeMiddleware | None:
if filesystem_mode != FilesystemMode.CLOUD:
return None
return KnowledgeTreeMiddleware(
workspace_id=workspace_id,
search_space_id=search_space_id,
filesystem_mode=filesystem_mode,
llm=llm,
inject_system_message=False,

View file

@ -1,7 +1,7 @@
"""Workspace-tree middleware for the SurfSense agent.
Renders the full ``Folder``+``Document`` tree under ``/documents/`` once per
turn (cloud only), caches it by ``(workspace_id, tree_version)``, and
turn (cloud only), caches it by ``(search_space_id, tree_version)``, and
injects the result as a ``<workspace_tree>`` system message immediately
before the latest human turn.
@ -106,14 +106,14 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
def __init__(
self,
*,
workspace_id: int,
search_space_id: int,
filesystem_mode: FilesystemMode,
llm: BaseChatModel | None = None,
max_entries: int = MAX_TREE_ENTRIES,
max_tokens: int = MAX_TREE_TOKENS,
inject_system_message: bool = True, # For backwards compatibility
) -> None:
self.workspace_id = workspace_id
self.search_space_id = search_space_id
self.filesystem_mode = filesystem_mode
self.llm = llm
self.max_entries = max_entries
@ -141,7 +141,7 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
cache_outcome = "anon"
else:
version = int(state.get("tree_version") or 0)
cache_key = (self.workspace_id, version, False)
cache_key = (self.search_space_id, version, False)
cache_outcome = "hit" if cache_key in self._cache else "miss"
tree_msg = await self._render_kb_tree(state)
@ -158,7 +158,7 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
cache_outcome,
len(tree_msg),
time.perf_counter() - start,
self.workspace_id,
self.search_space_id,
)
return update
@ -190,17 +190,17 @@ class KnowledgeTreeMiddleware(AgentMiddleware): # type: ignore[type-arg]
async def _render_kb_tree(self, state: AgentState) -> str:
version = int(state.get("tree_version") or 0)
cache_key = (self.workspace_id, version, False)
cache_key = (self.search_space_id, version, False)
cached = self._cache.get(cache_key)
if cached is not None:
return cached
try:
async with shielded_async_session() as session:
index = await build_path_index(session, self.workspace_id)
index = await build_path_index(session, self.search_space_id)
doc_rows = await session.execute(
select(Document.id, Document.title, Document.folder_id).where(
Document.workspace_id == self.workspace_id
Document.search_space_id == self.search_space_id
)
)
docs = list(doc_rows.all())

View file

@ -10,11 +10,11 @@ from .middleware import MemoryInjectionMiddleware
def build_memory_mw(
*,
user_id: str | None,
workspace_id: int,
search_space_id: int,
visibility: ChatVisibility,
) -> MemoryInjectionMiddleware:
return MemoryInjectionMiddleware(
user_id=user_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
thread_visibility=visibility,
)

View file

@ -18,7 +18,7 @@ from langgraph.runtime import Runtime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import ChatVisibility, User, Workspace, shielded_async_session
from app.db import ChatVisibility, SearchSpace, User, shielded_async_session
from app.services.memory import MEMORY_HARD_LIMIT, MEMORY_SOFT_LIMIT
from app.utils.perf import get_perf_logger
@ -35,11 +35,11 @@ class MemoryInjectionMiddleware(AgentMiddleware): # type: ignore[type-arg]
self,
*,
user_id: str | UUID | None,
workspace_id: int,
search_space_id: int,
thread_visibility: ChatVisibility | None = None,
) -> None:
self.user_id = UUID(user_id) if isinstance(user_id, str) else user_id
self.workspace_id = workspace_id
self.search_space_id = search_space_id
self.visibility = thread_visibility or ChatVisibility.PRIVATE
async def abefore_agent( # type: ignore[override]
@ -149,8 +149,8 @@ class MemoryInjectionMiddleware(AgentMiddleware): # type: ignore[type-arg]
async def _load_team_memory(self, session: AsyncSession) -> str | None:
try:
result = await session.execute(
select(Workspace.shared_memory_md).where(
Workspace.id == self.workspace_id
select(SearchSpace.shared_memory_md).where(
SearchSpace.id == self.search_space_id
)
)
row = result.scalar_one_or_none()

View file

@ -21,7 +21,7 @@ from ..plugins.loader import (
def build_plugin_middlewares(
*,
flags: AgentFeatureFlags,
workspace_id: int,
search_space_id: int,
user_id: str | None,
visibility: ChatVisibility,
llm: BaseChatModel,
@ -34,7 +34,7 @@ def build_plugin_middlewares(
return []
return load_plugin_middlewares(
PluginContext.build(
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
thread_visibility=visibility,
llm=llm,

View file

@ -17,13 +17,13 @@ def build_skills_mw(
*,
flags: AgentFeatureFlags,
filesystem_mode: FilesystemMode,
workspace_id: int,
search_space_id: int,
) -> SkillsMiddleware | None:
if not enabled(flags, "enable_skills"):
return None
try:
skills_factory = build_skills_backend_factory(
workspace_id=workspace_id
search_space_id=search_space_id
if filesystem_mode == FilesystemMode.CLOUD
else None,
)

View file

@ -1,17 +1,10 @@
"""Main-agent middleware list assembly: one line per slot.
The main agent is a pure router both filesystem reads/writes AND knowledge-base
retrieval are owned by the ``knowledge_base`` subagent and reached via the
``task`` tool. That subagent runs the hybrid ``search_knowledge_base`` (rendering
``<retrieved_context>`` with ``[n]`` citation labels) and the FS tools on demand;
the main agent only sees the specialist's grounded summary. The stack here
computes the workspace tree, commits any subagent-side staged writes at end of
turn (cloud mode), and wires the supporting middleware.
One deliberate, read-only exception to the pure-router stance: the main agent
also carries the ``read_run``/``search_run`` tools (added in ``runtime/factory``)
so it can follow the context-editing spill placeholder evicted tool output is
persisted to ``tool_output_spills`` and the placeholder advertises those tools.
The main agent is a pure router filesystem reads/writes are owned by the
``knowledge_base`` subagent and delegated via the ``task`` tool. The stack
here only renders KB context (workspace tree + priority docs), projects it
into system messages, and commits any subagent-side staged writes at end of
turn (cloud mode).
"""
from __future__ import annotations
@ -40,6 +33,9 @@ from app.agents.chat.multi_agent_chat.shared.middleware.anthropic_cache import (
from app.agents.chat.multi_agent_chat.shared.middleware.compaction import (
build_compaction_mw,
)
from app.agents.chat.multi_agent_chat.shared.middleware.kb_context_projection import (
build_kb_context_projection_mw,
)
from app.agents.chat.multi_agent_chat.shared.middleware.patch_tool_calls import (
build_patch_tool_calls_mw,
)
@ -88,6 +84,7 @@ from .context_editing import build_context_editing_mw
from .dedup_hitl import build_dedup_hitl_mw
from .doom_loop import build_doom_loop_mw
from .kb_persistence import build_kb_persistence_mw
from .knowledge_priority import build_knowledge_priority_mw
from .knowledge_tree import build_knowledge_tree_mw
from .noop_injection import build_noop_injection_mw
from .otel_span import build_otel_mw
@ -104,7 +101,7 @@ def build_main_agent_deepagent_middleware(
tools: Sequence[BaseTool],
backend_resolver: Any,
filesystem_mode: FilesystemMode,
workspace_id: int,
search_space_id: int,
user_id: str | None,
thread_id: int | None,
visibility: ChatVisibility,
@ -125,7 +122,7 @@ def build_main_agent_deepagent_middleware(
memory_mw = build_memory_mw(
user_id=user_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
visibility=visibility,
)
@ -237,19 +234,29 @@ def build_main_agent_deepagent_middleware(
),
build_knowledge_tree_mw(
filesystem_mode=filesystem_mode,
workspace_id=workspace_id,
search_space_id=search_space_id,
llm=llm,
),
build_knowledge_priority_mw(
llm=llm,
search_space_id=search_space_id,
filesystem_mode=filesystem_mode,
available_connectors=available_connectors,
available_document_types=available_document_types,
mentioned_document_ids=mentioned_document_ids,
preinjection_enabled=flags.enable_kb_priority_preinjection,
),
build_kb_context_projection_mw(),
build_kb_persistence_mw(
filesystem_mode=filesystem_mode,
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
thread_id=thread_id,
),
build_skills_mw(
flags=flags,
filesystem_mode=filesystem_mode,
workspace_id=workspace_id,
search_space_id=search_space_id,
),
SurfSenseCheckpointedSubAgentMiddleware(
checkpointer=checkpointer,
@ -257,7 +264,7 @@ def build_main_agent_deepagent_middleware(
subagents=subagents,
system_prompt=None,
task_description=TASK_TOOL_DESCRIPTION,
workspace_id=workspace_id,
search_space_id=search_space_id,
),
resilience.model_call_limit,
resilience.tool_call_limit,
@ -265,7 +272,7 @@ def build_main_agent_deepagent_middleware(
flags=flags,
max_input_tokens=max_input_tokens,
tools=tools,
workspace_id=workspace_id,
backend_resolver=backend_resolver,
),
build_compaction_mw(llm),
build_noop_injection_mw(flags),
@ -277,14 +284,14 @@ def build_main_agent_deepagent_middleware(
build_action_log_mw(
flags=flags,
thread_id=thread_id,
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
),
build_patch_tool_calls_mw(),
build_dedup_hitl_mw(tools),
*build_plugin_middlewares(
flags=flags,
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
visibility=visibility,
llm=llm,

View file

@ -57,7 +57,7 @@ class PluginContext(dict):
Backed by ``dict`` so plugins can inspect the keys they care about
without coupling to a concrete dataclass shape. Required keys:
* ``workspace_id`` (int)
* ``search_space_id`` (int)
* ``user_id`` (str | None)
* ``thread_visibility`` (:class:`app.db.ChatVisibility`)
* ``llm`` (:class:`langchain_core.language_models.BaseChatModel`)
@ -72,13 +72,13 @@ class PluginContext(dict):
def build(
cls,
*,
workspace_id: int,
search_space_id: int,
user_id: str | None,
thread_visibility: ChatVisibility,
llm: BaseChatModel,
) -> PluginContext:
return cls(
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
thread_visibility=thread_visibility,
llm=llm,

View file

@ -7,7 +7,7 @@ result. This particular plugin is read-only and only transforms the
mutation).
The plugin is built as a factory function so the entry-point loader can
inject :class:`PluginContext` (containing the agent's LLM, workspace
inject :class:`PluginContext` (containing the agent's LLM, search-space
ID, etc.). The factory signature
``Callable[[PluginContext], AgentMiddleware]`` is the only contract --
SurfSense doesn't define a custom plugin protocol on top of LangChain's

View file

@ -42,7 +42,7 @@ async def build_agent_with_cache(
final_system_prompt: str,
backend_resolver: Any,
filesystem_mode: FilesystemMode,
workspace_id: int,
search_space_id: int,
user_id: str | None,
thread_id: int | None,
visibility: ChatVisibility,
@ -57,7 +57,7 @@ async def build_agent_with_cache(
mcp_tools_by_agent: dict[str, list[BaseTool]],
disabled_tools: list[str] | None,
config_id: str | None,
image_gen_model_id_override: int | None = None,
image_generation_config_id_override: int | None = None,
) -> Any:
"""Compile the multi-agent graph, serving from cache when key components are stable."""
@ -69,7 +69,7 @@ async def build_agent_with_cache(
final_system_prompt=final_system_prompt,
backend_resolver=backend_resolver,
filesystem_mode=filesystem_mode,
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
thread_id=thread_id,
visibility=visibility,
@ -104,7 +104,7 @@ async def build_agent_with_cache(
config_id,
None if cross_thread else thread_id,
user_id,
workspace_id,
search_space_id,
visibility,
filesystem_mode,
anon_session_id,
@ -120,8 +120,8 @@ async def build_agent_with_cache(
sorted(disabled_tools) if disabled_tools else None,
# Bound into the generate_image subagent tool at construction time, so it
# must key the compiled-agent cache to avoid leaking one automation's
# image model into another with the same config_id/workspace.
image_gen_model_id_override,
# image model into another with the same config_id/search_space.
image_generation_config_id_override,
)
return await get_cache().get_or_build(cache_key, builder=_build)

View file

@ -26,7 +26,7 @@ Why a per-thread key (not a global pool)
----------------------------------------
Most middleware in the SurfSense stack captures per-thread state in
``__init__`` closures (``thread_id``, ``user_id``, ``workspace_id``,
``__init__`` closures (``thread_id``, ``user_id``, ``search_space_id``,
``filesystem_mode``, ``mentioned_document_ids``). Cross-thread reuse
would silently leak state across users and threads. Keying the cache on
``(llm_config_id, thread_id, ...)`` gives us safe reuse for repeated
@ -34,7 +34,7 @@ turns on the same thread without changing any middleware's behavior.
Phase 2 will move those captured fields onto :class:`SurfSenseContextSchema`
(read via ``runtime.context``) so the cache can collapse to a single
``(llm_config_id, workspace_id, ...)`` key shared across threads. Until
``(llm_config_id, search_space_id, ...)`` key shared across threads. Until
then, per-thread keying is the only safe option.
Cache shape
@ -111,7 +111,7 @@ def tools_signature(
* A tool is added or removed from the bound list (built-in toggles,
MCP tools loaded for the user changes, gating rules flip, etc.).
* The available connectors / document types for the workspace
* The available connectors / document types for the search space
change (new connector added, last connector removed, new document
type indexed). Connector gating derives disabled tools from
``available_connectors``, so the tool surface is technically already

View file

@ -1,10 +1,10 @@
"""Map configured connectors to the searchable document/connector types.
This is agent-agnostic infrastructure shared by every agent factory (single-
and multi-agent). It translates the connectors a workspace has enabled into
the set of searchable type strings that pre-search middleware understands, and
always layers in the document types that exist independently of any connector
(uploads, notes, extension captures, YouTube).
and multi-agent). It translates the connectors a search space has enabled into
the set of searchable type strings that pre-search middleware and ``web_search``
understand, and always layers in the document types that exist independently of
any connector (uploads, notes, extension captures, YouTube).
It lives in its own module rather than inside a specific agent factory so
that retiring or moving any single agent never disturbs the others' access to
@ -16,8 +16,15 @@ from __future__ import annotations
from typing import Any
# Maps SearchSourceConnectorType enum values to the searchable document/connector types
# used by KB pre-search middleware. All entries are local/indexed data.
# used by pre-search middleware and web_search.
# Live search connectors (TAVILY_API, LINKUP_API, BAIDU_SEARCH_API) are routed to
# the web_search tool; all others are considered local/indexed data.
_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
# Live search connectors (handled by web_search tool)
"TAVILY_API": "TAVILY_API",
"LINKUP_API": "LINKUP_API",
"BAIDU_SEARCH_API": "BAIDU_SEARCH_API",
# Local/indexed connectors (handled by KB pre-search middleware)
"SLACK_CONNECTOR": "SLACK_CONNECTOR",
"TEAMS_CONNECTOR": "TEAMS_CONNECTOR",
"NOTION_CONNECTOR": "NOTION_CONNECTOR",
@ -33,14 +40,12 @@ _CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
"AIRTABLE_CONNECTOR": "AIRTABLE_CONNECTOR",
"LUMA_CONNECTOR": "LUMA_CONNECTOR",
"ELASTICSEARCH_CONNECTOR": "ELASTICSEARCH_CONNECTOR",
"WEBCRAWLER_CONNECTOR": "CRAWLED_URL", # Maps to document type
"BOOKSTACK_CONNECTOR": "BOOKSTACK_CONNECTOR",
"CIRCLEBACK_CONNECTOR": "CIRCLEBACK", # Connector type differs from document type
"OBSIDIAN_CONNECTOR": "OBSIDIAN_CONNECTOR",
"DROPBOX_CONNECTOR": "DROPBOX_FILE", # Connector type differs from document type
"ONEDRIVE_CONNECTOR": "ONEDRIVE_FILE", # Connector type differs from document type
# Generic user-defined MCP server: unlocks the mcp_discovery subagent even
# in a workspace with no hosted-service connectors.
"MCP_CONNECTOR": "MCP_CONNECTOR",
# Composio connectors (unified to native document types).
# Reverse of NATIVE_TO_LEGACY_DOCTYPE in app.db.
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "GOOGLE_DRIVE_FILE",

View file

@ -34,7 +34,6 @@ from app.agents.chat.runtime.llm_config import AgentConfig
from app.agents.chat.runtime.prompt_caching import (
apply_litellm_prompt_caching,
)
from app.auth.context import AuthContext
from app.db import ChatVisibility
from app.services.connector_service import ConnectorService
from app.services.user_tool_allowlist import (
@ -58,7 +57,7 @@ _perf_log = get_perf_logger()
async def create_multi_agent_chat_deep_agent(
llm: BaseChatModel,
workspace_id: int,
search_space_id: int,
db_session: AsyncSession,
connector_service: ConnectorService,
checkpointer: Checkpointer,
@ -68,18 +67,18 @@ async def create_multi_agent_chat_deep_agent(
enabled_tools: list[str] | None = None,
disabled_tools: list[str] | None = None,
additional_tools: Sequence[BaseTool] | None = None,
firecrawl_api_key: str | None = None,
thread_visibility: ChatVisibility | None = None,
mentioned_document_ids: list[int] | None = None,
anon_session_id: str | None = None,
filesystem_selection: FilesystemSelection | None = None,
image_gen_model_id: int | None = None,
auth_context: AuthContext | None = None,
image_generation_config_id: int | None = None,
):
"""Deep agent with SurfSense tools/middleware; registry route subagents behind ``task`` when enabled.
``image_gen_model_id`` overrides the workspace's image model for
``image_generation_config_id`` overrides the search space's image model for
this invocation (used by automations to run on their captured model). When
``None``, the ``generate_image`` tool resolves the live workspace pref.
``None``, the ``generate_image`` tool resolves the live search-space pref.
"""
_t_agent_total = time.perf_counter()
@ -88,7 +87,7 @@ async def create_multi_agent_chat_deep_agent(
filesystem_selection = filesystem_selection or FilesystemSelection()
backend_resolver = build_backend_resolver(
filesystem_selection,
workspace_id=workspace_id
search_space_id=search_space_id
if filesystem_selection.mode == FilesystemMode.CLOUD
else None,
)
@ -98,11 +97,13 @@ async def create_multi_agent_chat_deep_agent(
_t0 = time.perf_counter()
try:
connector_types = await connector_service.get_available_connectors(workspace_id)
connector_types = await connector_service.get_available_connectors(
search_space_id
)
available_connectors = map_connectors_to_searchable_types(connector_types)
available_document_types = await connector_service.get_available_document_types(
workspace_id
search_space_id
)
except Exception as e:
@ -133,11 +134,11 @@ async def create_multi_agent_chat_deep_agent(
)
dependencies: dict[str, Any] = {
"workspace_id": workspace_id,
"search_space_id": search_space_id,
"db_session": db_session,
"connector_service": connector_service,
"firecrawl_api_key": firecrawl_api_key,
"user_id": user_id,
"auth_context": auth_context,
"thread_id": thread_id,
"thread_visibility": visibility,
"available_connectors": available_connectors,
@ -146,12 +147,14 @@ async def create_multi_agent_chat_deep_agent(
"llm": llm,
# Per-invocation image model override (automations run on their captured
# model). Reaches the generate_image subagent tool via subagent_dependencies.
"image_gen_model_id_override": image_gen_model_id,
"image_generation_config_id_override": image_generation_config_id,
}
_t0 = time.perf_counter()
try:
mcp_tools_by_agent = await load_mcp_tools_by_connector(db_session, workspace_id)
mcp_tools_by_agent = await load_mcp_tools_by_connector(
db_session, search_space_id
)
except Exception as e:
# Degrade to builtins-only rather than aborting the turn: a transient
# DB or MCP-server hiccup should not deny the user a response.
@ -187,7 +190,7 @@ async def create_multi_agent_chat_deep_agent(
user_allowlist_by_subagent = await fetch_user_allowlist_rulesets(
db_session,
user_id=user_uuid,
workspace_id=workspace_id,
search_space_id=search_space_id,
)
except Exception as e:
logging.warning(
@ -224,15 +227,6 @@ async def create_multi_agent_chat_deep_agent(
additional_tools=list(additional_tools) if additional_tools else None,
)
# Read-only exception to the "main agent is a pure router" stance: the
# context-editing spill placeholder points at read_run/search_run, so the
# main agent needs those tools to follow it. See middleware/stack.py.
from app.agents.chat.multi_agent_chat.subagents.shared.run_reader import (
build_run_reader_tools,
)
tools = [*list(tools), *build_run_reader_tools(workspace_id=workspace_id)]
_flags: AgentFeatureFlags = get_flags()
if _flags.enable_tool_call_repair and INVALID_TOOL_NAME not in {
t.name for t in tools
@ -294,7 +288,7 @@ async def create_multi_agent_chat_deep_agent(
final_system_prompt=final_system_prompt,
backend_resolver=backend_resolver,
filesystem_mode=filesystem_selection.mode,
workspace_id=workspace_id,
search_space_id=search_space_id,
user_id=user_id,
thread_id=thread_id,
visibility=visibility,
@ -309,7 +303,7 @@ async def create_multi_agent_chat_deep_agent(
mcp_tools_by_agent=mcp_tools_by_agent,
disabled_tools=disabled_tools,
config_id=config_id,
image_gen_model_id_override=image_gen_model_id,
image_generation_config_id_override=image_generation_config_id,
)
_perf_log.info(
"[create_agent] Middleware stack + graph compiled in %.3fs",

View file

@ -17,12 +17,12 @@ Two backends are provided:
* :class:`BuiltinSkillsBackend` disk-backed read of bundled skills from
``app/agents/shared/skills/builtin/``.
* :class:`WorkspaceSkillsBackend` a thin read-only wrapper over
* :class:`SearchSpaceSkillsBackend` a thin read-only wrapper over
:class:`KBPostgresBackend` that filters notes under the privileged folder
``/documents/_skills/``.
Both backends are intentionally read-only: skill authoring happens out of band
(via filesystem or a workspace-admin route), so we never expose
(via filesystem or a search-space-admin route), so we never expose
``write`` / ``edit`` / ``upload_files``. The base class' ``NotImplementedError``
gives a clean failure mode if anything tries.
"""
@ -181,12 +181,12 @@ class BuiltinSkillsBackend(BackendProtocol):
return responses
class WorkspaceSkillsBackend(BackendProtocol):
"""Read-only view of workspace-authored skills.
class SearchSpaceSkillsBackend(BackendProtocol):
"""Read-only view of search-space-authored skills.
Wraps a :class:`KBPostgresBackend` and only ever reads under the privileged
folder ``/documents/_skills/`` (configurable). The folder is intended to be
writable only by workspace admins; this backend never writes.
writable only by search-space admins; this backend never writes.
The skills middleware expects a layout like::
@ -236,14 +236,14 @@ class WorkspaceSkillsBackend(BackendProtocol):
# path falls back to ``asyncio.to_thread(...)`` in the base class. We
# keep this stub to satisfy abstract resolution; the middleware calls
# ``als_info``.
raise NotImplementedError("WorkspaceSkillsBackend is async-only")
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
async def als_info(self, path: str) -> list[FileInfo]:
kb_path = self._to_kb(path)
try:
infos = await self._kb.als_info(kb_path)
except Exception as exc: # pragma: no cover - defensive
logger.warning("WorkspaceSkillsBackend.als_info failed: %s", exc)
logger.warning("SearchSpaceSkillsBackend.als_info failed: %s", exc)
return []
remapped: list[FileInfo] = []
for info in infos:
@ -254,7 +254,7 @@ class WorkspaceSkillsBackend(BackendProtocol):
return remapped
def download_files(self, paths: list[str]) -> list[FileDownloadResponse]:
raise NotImplementedError("WorkspaceSkillsBackend is async-only")
raise NotImplementedError("SearchSpaceSkillsBackend is async-only")
async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]:
kb_paths = [self._to_kb(p) for p in paths]
@ -274,16 +274,16 @@ SKILLS_SPACE_PREFIX = "/skills/space/"
def build_skills_backend_factory(
*,
builtin_root: Path | str | None = None,
workspace_id: int | None = None,
search_space_id: int | None = None,
) -> Callable[[ToolRuntime], BackendProtocol]:
"""Return a runtime-aware factory for the skills :class:`CompositeBackend`.
When ``workspace_id`` is provided the composite includes a
:class:`WorkspaceSkillsBackend` route at ``/skills/space/`` over a fresh
When ``search_space_id`` is provided the composite includes a
:class:`SearchSpaceSkillsBackend` route at ``/skills/space/`` over a fresh
per-runtime :class:`KBPostgresBackend`, mirroring how
:func:`build_backend_resolver` constructs the main filesystem backend.
When ``workspace_id`` is ``None`` (e.g., desktop-local mode or unit
When ``search_space_id`` is ``None`` (e.g., desktop-local mode or unit
tests) only the bundled :class:`BuiltinSkillsBackend` is exposed.
Returning a factory rather than a fixed instance is intentional: the
@ -293,7 +293,7 @@ def build_skills_backend_factory(
"""
builtin = BuiltinSkillsBackend(builtin_root)
if workspace_id is None:
if search_space_id is None:
def _factory_builtin_only(runtime: ToolRuntime) -> BackendProtocol:
# Default StateBackend is intentionally inert: any path outside the
@ -314,8 +314,8 @@ def build_skills_backend_factory(
KBPostgresBackend,
)
kb = KBPostgresBackend(workspace_id, runtime)
space = WorkspaceSkillsBackend(kb)
kb = KBPostgresBackend(search_space_id, runtime)
space = SearchSpaceSkillsBackend(kb)
return CompositeBackend(
default=StateBackend(runtime),
routes={
@ -336,7 +336,7 @@ __all__ = [
"SKILLS_BUILTIN_PREFIX",
"SKILLS_SPACE_PREFIX",
"BuiltinSkillsBackend",
"WorkspaceSkillsBackend",
"SearchSpaceSkillsBackend",
"build_skills_backend_factory",
"default_skills_sources",
]

View file

@ -1,7 +1,7 @@
---
name: kb-research
description: Structured approach to finding and synthesizing information from the user's knowledge base
allowed-tools: read_file, ls_tree, grep
allowed-tools: scrape_webpage, read_file, ls_tree, grep, web_search
---
# Knowledge-base research
@ -15,7 +15,7 @@ allowed-tools: read_file, ls_tree, grep
1. Decompose the user's question into 2-4 specific, citation-worthy sub-questions.
2. For each sub-question, run **one** targeted KB search (focused on terms the user would have written, not synonyms). Open the most relevant 2-3 documents fully via `read_file` if their excerpts are too short.
3. Use `grep` to find supporting passages in long files instead of re-reading them end to end.
4. Cite every claim with the `[n]` label shown on the passage you used (search results and `read_file` output both carry them); never write a chunk id, URL, or title yourself.
4. Cite every claim with `[citation:chunk_id]` exactly as the chunk tag specifies.
## What good output looks like
- Short paragraphs with inline citations.

View file

@ -1,7 +1,7 @@
---
name: meeting-prep
description: Pull together briefing materials before a scheduled meeting
allowed-tools: task, read_file
allowed-tools: web_search, scrape_webpage, read_file
---
# Meeting preparation
@ -12,11 +12,11 @@ The user mentions an upcoming meeting, call, or interview and asks you to "prep"
## Output structure
Always produce these sections (omit any with no signal — don't pad):
1. **Attendees & context** — who's in the room, their roles, what they care about. Pull from KB notes about prior interactions; supplement with public profile facts via `task(google_search, ...)` when names or companies are unfamiliar.
1. **Attendees & context** — who's in the room, their roles, what they care about. Pull from KB notes about prior interactions; supplement with public profile facts via `web_search` when names or companies are unfamiliar.
2. **Open threads** — outstanding action items, unresolved decisions, last-mentioned blockers from prior conversation history.
3. **Recent moves** — within the last 30 days: relevant launches, hires, news. Cite KB chunks when present, otherwise external sources.
4. **Suggested questions** — 3-5 questions the user could ask, tailored to the open threads and the attendees' likely priorities.
## Source ordering
- Always check the user's KB **first** for prior meeting notes, internal docs, or Slack threads about these attendees.
- Only fall back to `task(google_search, ...)` for *publicly verifiable* facts — never to fabricate a participant's preferences or relationships.
- Only fall back to `web_search` for *publicly verifiable* facts — never to fabricate a participant's preferences or relationships.

View file

@ -1,13 +1,12 @@
<citations>
Citation markers are **disabled** in this configuration.
Do NOT include `[n]` citation labels or `[citation:…]` markers anywhere, even if
tool output (`<retrieved_context>`, `<web_results>`), tool descriptions, or
Do NOT include `[citation:…]` markers anywhere, even if tool descriptions or
examples reference them. Ignore citation-format reminders elsewhere in this
prompt when they conflict with this block.
1. Answer in plain prose. Optional markdown links to public URLs when
sources are URLs.
2. Do not expose raw chunk ids, document ids, or internal ids to the user.
3. Present KB, web, or docs facts naturally without attribution markers.
3. Present KB or docs facts naturally without attribution markers.
</citations>

View file

@ -1,17 +1,42 @@
<citations>
Cite with one token: the bracket label `[n]`. Every citable result —
prose from a `task` knowledge_base/research specialist (including the
knowledge_base specialist's `[n]`-labelled workspace findings) — already
carries `[n]` labels on a single shared count.
Those labels are the only citation you write; the server resolves each one
back to its source after the turn.
Citations reach the answer through two channels. Use whichever applies — and
never invent ids you didn't see. Citation ids are resolved by exact-match
lookup; a wrong id silently breaks the link, so when in doubt, omit.
1. Put the label right after the claim it supports.
2. Several sources for one claim: stack brackets, `[1][2]`.
3. Copy labels exactly as shown, a specialist's included — never renumber them,
add your own, or write the underlying title, date, id, or URL instead.
4. Write the bare `[n]` and nothing else: no `[citation:...]`, no markdown links,
no footnote marks, no "References" section.
5. Only label claims the sources support. If nothing shown backs a claim — or you
never saw a label — leave it uncited; never invent one.
### Channel A — chunk blocks injected this turn
When `web_search` returns `<document>` / `<chunk id='…'>` blocks in this
turn:
1. For each factual statement taken from those chunks, add
`[citation:chunk_id]` using the **exact** id from a visible
`<chunk id='…'>` tag. Copy digit-for-digit (or the URL verbatim);
do not retype from memory.
2. `<document_id>` is the parent doc id, **not** a citation source —
only ids inside `<chunk id='…'>` count.
3. Multiple chunks → `[citation:id1], [citation:id2]` (comma-separated,
each id copied individually).
4. Never invent, normalise, or guess at adjacent ids; if unsure, omit.
5. Plain brackets only — no markdown links, no footnote numbering.
### Channel B — citations relayed by a `task` specialist
A `task(...)` tool message may contain `[citation:<chunk_id>]` markers
the specialist already attached to its prose. The specialist saw the
underlying `<chunk id='…'>` blocks; you didn't. So:
1. **Preserve those markers verbatim** in your final answer — do not
reformat, renumber, drop, or wrap them in markdown links. When you
paraphrase a specialist sentence, copy the marker character-for-
character; do not regenerate the id from memory (LLMs reliably
corrupt nearby digits).
2. Keep each marker attached to the sentence the specialist attached
it to.
3. Do **not** add new `[citation:…]` markers of your own to a
specialist's prose; if a fact has no marker, the specialist
couldn't tie it to a chunk and neither can you.
4. When a specialist returns JSON, the citation markers live inside
the prose-bearing fields (e.g. a summary or excerpt). Pull them
along with the surrounding sentence when you quote.
If neither channel surfaces citation markers this turn, do not fabricate
them.
</citations>

View file

@ -8,15 +8,20 @@ standing instructions. It also reports current character usage versus the
hard limit so you can manage the budget. Treat it as background colour for
your answer, not as the task itself.
`<priority_documents>` lists the workspace documents most relevant to the
latest user message, ranked by relevance score, with `[USER-MENTIONED]`
flagged on anything the user explicitly referenced. When the task is about
workspace content, read these first; matched passages inside each document
are flagged via `<chunk_index>` so you can jump straight to them.
`<workspace_tree>` shows the full `/documents/` folder and file layout. Use
it to resolve paths the user describes in natural language ("my Q2 roadmap",
"last week's meeting notes") into concrete document references before
delegating to a specialist.
Knowledge-base passages are no longer injected here directly: delegate to the
`knowledge_base` specialist via `task`, which runs the hybrid search/read and
returns a grounded summary already carrying `[n]` citation labels for you to
carry through.
`<document>` and `<chunk id='…'>` blocks are chunked indexed content returned
by KB search (backing `<priority_documents>`). Each chunk carries a stable
`id` attribute.
If no grounding arrives this turn, work from the conversation alone.
If a block doesn't appear this turn, work from the conversation alone.
</dynamic_context>

View file

@ -7,15 +7,21 @@ decisions, conventions, architecture notes, processes, key facts. It also
reports current character usage versus the hard limit so you can manage the
budget. Treat it as background colour for your answer, not as the task itself.
`<priority_documents>` lists the workspace documents most relevant to the
latest user message, ranked by relevance score, with `[USER-MENTIONED]`
flagged on anything someone in the thread explicitly referenced. When the
task is about workspace content, read these first; matched passages inside
each document are flagged via `<chunk_index>` so you can jump straight to
them.
`<workspace_tree>` shows the full `/documents/` folder and file layout. Use
it to resolve paths described in natural language ("the Q2 roadmap", "last
week's planning notes") into concrete document references before delegating
to a specialist.
Knowledge-base passages are no longer injected here directly: delegate to the
`knowledge_base` specialist via `task`, which runs the hybrid search/read and
returns a grounded summary already carrying `[n]` citation labels for you to
carry through.
`<document>` and `<chunk id='…'>` blocks are chunked indexed content returned
by KB search (backing `<priority_documents>`). Each chunk carries a stable
`id` attribute.
If no grounding arrives this turn, work from the conversation alone.
If a block doesn't appear this turn, work from the conversation alone.
</dynamic_context>

View file

@ -1,23 +1,8 @@
<agent_identity>
You are **SurfSense's main agent**, the orchestrator of an open-source
competitive intelligence platform. Users come to you to understand their
market: what competitors are doing, how audiences react, where rankings and
reviews are moving, and what is being said across the open web — and to put
that intelligence to work alongside their own knowledge base.
You do this by dispatching **specialist subagents** via the `task` tool:
- **Live market data** — Reddit, YouTube, Google Maps, Google Search, and the
web crawler return structured, current platform data (posts, comments,
transcripts, reviews, SERPs, full page content).
- **The user's own context** — their knowledge base, connected apps, and
persistent memory.
- **Deliverables** — reports, podcasts, and presentations built from what the
specialists find.
You are an orchestrator — most non-trivial work belongs on a specialist. Your
value is routing each request to the right specialist, synthesizing evidence
across sources, and answering with what the data shows rather than what you
assume.
You are **SurfSense's main agent**. Your job is to answer the user using their
knowledge base, lightweight web research, persistent memory, and **specialist
subagents** invoked via the `task` tool. You are an orchestrator — most
non-trivial work belongs on a specialist.
Today (UTC): {resolved_today}
</agent_identity>

View file

@ -1,23 +1,8 @@
<agent_identity>
You are **SurfSense's main agent**, the orchestrator of an open-source
competitive intelligence platform. This team comes to you to understand its
market: what competitors are doing, how audiences react, where rankings and
reviews are moving, and what is being said across the open web — and to put
that intelligence to work alongside the team's shared knowledge base.
You do this by dispatching **specialist subagents** via the `task` tool:
- **Live market data** — Reddit, YouTube, Google Maps, Google Search, and the
web crawler return structured, current platform data (posts, comments,
transcripts, reviews, SERPs, full page content).
- **The team's own context** — its shared knowledge base, connected apps, and
persistent team memory.
- **Deliverables** — reports, podcasts, and presentations built from what the
specialists find.
You are an orchestrator — most non-trivial work belongs on a specialist. Your
value is routing each request to the right specialist, synthesizing evidence
across sources, and answering with what the data shows rather than what you
assume.
You are **SurfSense's main agent**. Your job is to answer the user using their
shared team knowledge base, lightweight web research, persistent memory, and
**specialist subagents** invoked via the `task` tool. You are an orchestrator
— most non-trivial work belongs on a specialist.
Today (UTC): {resolved_today}

View file

@ -1,27 +1,16 @@
<knowledge_base_first>
CRITICAL — ground factual answers in what you actually receive this turn:
- **live platform data** via the market specialists —
`task(reddit, ...)`, `task(youtube, ...)`, `task(google_maps, ...)`,
`task(google_search, ...)`, `task(web_crawler, ...)`. Anything about
competitors, markets, rankings, reviews, or audience sentiment is answered
from what these return **this turn**, never from your training data: your
general knowledge of companies, prices, and rankings is stale by definition,
- the user's knowledge base via `task(knowledge_base, ...)` (your PRIMARY
source for anything about their own uploaded files, documents, and notes —
the `<workspace_tree>` only lists what exists, so delegate to the specialist
to search and read the actual content before answering),
- the user's knowledge base via `search_knowledge_base` (your PRIMARY source
for anything about their documents, notes, or connected data — the
`<workspace_tree>` only lists what exists, so call the tool to read the
actual content before answering),
- injected workspace context (see `<dynamic_context>`),
- the user's connected apps via `task(mcp_discovery, ...)` (Slack, Jira,
Notion, Gmail, Calendar, etc. — live data that is NOT in the knowledge base),
- results from your other tool calls (`web_search`, `scrape_webpage`),
- or substantive summaries returned by a `task` specialist you invoked.
For questions about the user's own files and notes, dispatch
`task(knowledge_base, ...)` first rather than answering from the tree or from
memory. The knowledge_base specialist runs hybrid semantic/keyword search and
full-document reads over their personal files and notes, then returns a
grounded summary with `[n]` citation labels for you to carry into your answer.
For anything living in a connected third-party app, use
`task(mcp_discovery, ...)` instead — that content is not indexed in the KB.
For questions about the user's own workspace, call `search_knowledge_base`
first rather than answering from the tree or from memory. Use
`task(knowledge_base)` when you need a document's full text or deeper reads.
Do **not** answer factual or informational questions from general knowledge
unless the user explicitly authorises it after you say you couldn't find

View file

@ -5,7 +5,7 @@ Structured reasoning:
- For non-trivial work, `<thinking>` / short `<plan>` before tool calls is fine.
Professional objectivity:
- Accuracy over flattery; verify with **task** (e.g. `task(web_crawler, …)` to read a page, `task(google_search, …)` for public facts) when unsure — dont invent connector access.
- Accuracy over flattery; verify with **web_search**, **scrape_webpage**, or **task** when unsure — dont invent connector access.
Task management:
- For 3+ steps, use todo tooling; update statuses promptly.

View file

@ -14,5 +14,5 @@ Workflow (Understand → Plan → Act → Verify):
Discipline:
- Do not imply access to connectors, MCP tools, or deliverable generators except via **task**.
- Pass paths to **task(knowledge_base, …)** only when you saw them in `<workspace_tree>`. Otherwise describe the document in natural language and let the subagent resolve it.
- Pass paths to **task(knowledge_base, …)** only when you saw them in `<workspace_tree>` or `<priority_documents>`. Otherwise describe the document in natural language and let the subagent resolve it.
</provider_hints>

View file

@ -8,8 +8,8 @@ Tool discipline:
- Typically one investigative tool per turn unless several independent read-only queries are clearly needed; dont repeat identical calls.
Attribution:
- When citations are **enabled** (see citation block above) and you answer from labelled passages, cite with the bare `[n]` label exactly as specified there.
- When citations are **disabled**, never emit `[n]` or `[citation:…]` — plain prose and links per tool guidance.
- When citations are **enabled** (see citation block above) and you answer from chunk-tagged documents, use `[citation:chunk_id]` exactly as specified there.
- When citations are **disabled**, never emit `[citation:…]` — plain prose and links per tool guidance.
Style:
- No emojis unless asked; flat lists for short answers.

View file

@ -16,6 +16,6 @@ Output style:
Tool calls:
- Parallelise independent calls in one turn.
- For SurfSense-product questions, point the user to https://www.surfsense.com/docs;
use **task(google_search, …)** / **task(web_crawler, …)** for fresh public facts; integrations and
use **web_search** / **scrape_webpage** for fresh public facts; integrations and
heavy workflows → **task**.
</provider_hints>

View file

@ -3,7 +3,7 @@ You are running on an OpenAI Codex-class model (SurfSense **main agent**).
Output style:
- Concise; dont paste huge fetch blobs — summarize.
- When citations are **enabled** and you rely on labelled passages, cite with the bare `[n]` label per the citation block above; when **disabled**, use prose and URLs only.
- When citations are **enabled** and you rely on chunk-tagged docs, references may use `[citation:chunk_id]` per the citation block above; when **disabled**, use prose and URLs only.
- Numbered lists work well when the user should reply with a single option index.
- No emojis; single-level bullets.

View file

@ -1,5 +1,4 @@
<reminder>
Concise · grounded in this turn's specialist data, never stale general
knowledge · delegation-first · no direct filesystem · persist memory when
durable facts appear.
Concise · KB-grounded · delegation-first · one `task` per turn · no direct
filesystem · persist memory when durable facts appear.
</reminder>

View file

@ -3,6 +3,8 @@ You have two execution channels. Pick the one that owns the work — never
simulate one with the other.
### 1. Direct tools (you call them yourself)
- `web_search` — search the public web (anything outside the workspace KB).
- `scrape_webpage` — fetch the body of a specific public URL.
- `update_memory` — curate persistent memory (see `<memory_protocol>`).
- `write_todos` — maintain a structured plan when the turn series spans
multiple specialists or steps. Mark each item
@ -13,63 +15,6 @@ simulate one with the other.
connectors, feature behavior) — point the user to the documentation:
https://www.surfsense.com/docs. There is no docs-search tool; give the link.
**Search discovers — the crawler reads.** Search results (snippets, AI
overviews, a specialist's summary of a SERP) are pointers, not sources.
When the answer lives on a page — a team roster, a portfolio or directory
listing, a pricing table, docs — fetch the page before answering:
- One or a few known URLs → `task(web_crawler, …)` with those URLs (it
fetches only the seeds at `maxCrawlDepth=0`).
- A site section or many pages (a whole team + portfolio, every pricing
page of a list of companies, a paginated directory) →
`task(web_crawler, …)` with the seed URLs and a higher depth.
Never answer with "you can find it at <URL>" for public facts your tools
can retrieve — retrieve them, then answer with the facts and cite the page.
Large results are fine: extract and return them, don't ask permission for
bounded fan-out (≤20 sites) the user already requested.
**Audience sentiment lives on the platforms.** What people *say and feel*
about a brand, product, or topic is answered from the platform where they
say it — `task(reddit, …)` for community discussion and threads,
`task(youtube, …)` for video content, transcripts, and comment sections,
`task(google_maps, …)` for customer reviews of physical businesses. Web
search only finds articles *about* the conversation; the platform
specialists return the conversation itself, structured and current. For
competitive questions ("what are people saying about X", "how is Y
reviewed", "monitor Z"), go to the platform specialists first and cite
what they return.
**Places go to Maps, the open web goes to Search.** Discovering physical
businesses or venues of a type in a geography ("clinics in X", "tutoring
centers near Y", lead lists of local businesses) is the Maps specialist's
job — it returns structured name/address/phone/website per place, where
web search returns only snippets that need a second pass. Use the Search
specialist for entities without a storefront (online-only companies,
software vendors, publications), for facts and current events, and to
enrich places Maps already found. When a lead list needs both, run Maps
discovery first, then crawl (`task(web_crawler, …)`) or search the found
websites for contacts.
**Requested-N lists count distinct entities that fit the ask.** When the
user asks for N leads/items/results, every entry must be a distinct
*entity* — multiple branches, locations, sub-programs, or pages of the
same brand or parent organization are ONE entry, not several. A website
domain is an ownership signal: entries whose pages live on the same parent
domain (a government portal, a chain's site, a franchise system) share
that parent and count as one, judged against the parent. Entries must
also fit the user's stated segment: an item that belongs to an excluded
category (e.g. a local branch of a large chain when the user asked for
independents) does not qualify even if a specialist returned it — drop it,
don't relay it. If qualifying results fall short of N, widen the discovery
(another specialist call, adjacent geography or segment) to fill the gap
honestly; if it still falls short, deliver the smaller list with a
one-line note. An honest 10 beats a padded 15.
**Full datasets become files, not chat.** When the user wants a complete
large dataset (an entire roster, portfolio, or directory — or asks for a
CSV/file), do not paste or summarize hundreds of rows: instruct the
web_crawler specialist to crawl and then save the data with its
`export_run` CSV tool, and relay the saved workspace path and row count.
**You have NO filesystem tools.** Any read, write, edit, move, rename, or
search inside the user's workspace goes through `task(knowledge_base, …)`
never via `write_file`, `ls`, or any direct file operation.
@ -119,42 +64,13 @@ user: "Save these meeting notes to my KB: …"
<example>
user: "What did Maya say about the Q2 roadmap in Slack last week?"
→ task(mcp_discovery, "In Slack, find messages from Maya about the Q2 roadmap
from the past week. Return the most relevant quotes with channel and
timestamp.")
</example>
<example>
user: "What are people saying about Cursor vs Windsurf lately?"
→ Audience sentiment — go to the platform, not web search. Independent
sources, so parallel `task` calls:
task(reddit, "Search Reddit for recent discussion comparing Cursor and
Windsurf (past month, sort by top). Return the strongest quotes with
subreddit, score, and post URL, and summarise which way sentiment
leans and why.")
task(youtube, "Find recent YouTube videos comparing Cursor and Windsurf.
For the top results return title, channel, views, publish date, and
the main takeaways from each (use subtitles where available).")
Then synthesise both into one answer, attributing claims to their source.
→ task(slack, "Find messages from Maya about the Q2 roadmap from the past
week. Return the most relevant quotes with channel and timestamp.")
</example>
<example>
user: "What's the current USD/INR rate?"
→ Public web lookup — delegate to the Google Search specialist:
task(google_search, "Search Google for the current USD to INR exchange
rate and return the rate with its source URL.")
</example>
<example>
user: "Get the a16z team and their portfolio companies."
→ Search only *locates* a16z.com/team/ and their investment list — the
answer is the CONTENT of those pages. Crawl them and return the extracted
people and companies, never just the links:
task(web_crawler, "Crawl https://a16z.com/team/ and
https://a16z.com/investment-list/ and return (1) the full team roster
with each person's name and role/department, and (2) the complete
portfolio company list. Use the pages' link records if the markdown
is sparse.")
→ web_search(query="current USD to INR exchange rate")
</example>
<example>
@ -166,16 +82,15 @@ user: "Find my Q2 roadmap and summarise the milestones."
<example>
user: "Create a ClickUp ticket and a Linear ticket for the new feature flag."
→ Independent work, same specialist (connected apps) with non-overlapping
scopes — call it twice in parallel, naming the target app in each prompt:
→ Independent work — call both specialists in parallel:
write_todos([
{content: "Create ClickUp ticket for feature flag rollout", status: "in_progress"},
{content: "Create Linear ticket for feature flag rollout", status: "in_progress"},
])
task(mcp_discovery, "In ClickUp, create a ticket titled 'Feature flag
rollout' in the default list. Description: <…>. Tell me the ticket URL.")
task(mcp_discovery, "In Linear, create a ticket titled 'Feature flag
rollout' in the default team. Description: <…>. Tell me the ticket URL.")
task(clickup, "Create a ClickUp ticket titled 'Feature flag rollout'
in the default list. Description: <…>. Tell me the ticket URL.")
task(linear, "Create a Linear ticket titled 'Feature flag rollout'
in the default team. Description: <…>. Tell me the ticket URL.")
</example>
<example>
@ -185,25 +100,24 @@ user: "Find my Q2 roadmap doc in the KB and email a summary to Maya."
task(knowledge_base, "Find the Q2 roadmap document under /documents
and return its full text plus a 3-bullet summary.")
Next turn (with the returned summary in hand):
task(mcp_discovery, "In Gmail, send an email to Maya with subject 'Q2
roadmap summary' and the following body: <summary returned by
knowledge_base>.")
task(gmail, "Send an email to Maya with subject 'Q2 roadmap summary'
and the following body: <summary returned by knowledge_base>.")
</example>
<example>
user: "Create issues in Linear for each of these five bugs: <list>"
→ Many-shot independent fanout — use the batch shape:
task(tasks=[
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 1 title>' with body '<bug 1 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 2 title>' with body '<bug 2 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 3 title>' with body '<bug 3 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 4 title>' with body '<bug 4 body>'. Return the issue URL."},
{subagent_type: "mcp_discovery", description: "In Linear, create an issue
titled '<bug 5 title>' with body '<bug 5 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 1 title>' with body '<bug 1 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 2 title>' with body '<bug 2 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 3 title>' with body '<bug 3 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 4 title>' with body '<bug 4 body>'. Return the issue URL."},
{subagent_type: "linear", description: "Create a Linear issue titled
'<bug 5 title>' with body '<bug 5 body>'. Return the issue URL."},
])
Read back the `[task 0]``[task 4]` blocks in the combined ToolMessage and
verify each via its Receipt's `verifiable_url` per the `<verification>`
@ -240,18 +154,16 @@ user: "Make a 30-second podcast of this conversation."
<example>
user: "Post the launch announcement to #general and let me know when it's up."
→ Mutating subagent + user wants external confirmation. Apply the
`<verification>` teaching: the connected-apps subagent's reply is a
self-report; check its `evidence.receipts` for a Receipt with
`status="success"` and a `verifiable_url`, then fetch that URL to confirm
before reporting back.
`<verification>` teaching: the slack subagent's reply is a self-report;
check its `evidence.receipts` for a Receipt with `status="success"` and
a `verifiable_url`, then fetch that URL to confirm before reporting back.
This turn:
task(mcp_discovery, "In Slack, post '<launch announcement text>' to
#general. Return the message permalink.")
task(slack, "Post '<launch announcement text>' to #general.
Return the message permalink.")
Next turn (with the receipt's `verifiable_url` in hand):
task(web_crawler, "Crawl <verifiable_url from the receipt> and confirm
the post is live; return what you find.")
scrape_webpage(url=<verifiable_url from slack receipt>)
→ confirm the post is live, then tell the user it's up with the URL.
If the reply has NO Receipt with `status="success"`, treat it as a
If the slack reply has NO Receipt with `status="success"`, treat it as a
silent failure: surface the error verbatim, do not retry.
</example>
</routing>

View file

@ -0,0 +1 @@
"""``scrape_webpage`` — description + few-shot examples."""

View file

@ -0,0 +1,11 @@
- `scrape_webpage` — Fetch and extract readable content from a single URL.
- Use when the user wants the actual page body (article, table, dashboard
snapshot), not just search snippets.
- Try the tool when a URL is given or referenced; don't refuse without
attempting unless the URL is clearly unsafe or invalid.
- Public web only. For URLs behind a connector (Notion pages, Linear
issues, Confluence, anything that needs auth), use `task` with the
matching specialist instead.
- Args: `url`, `max_length` (default 50000).
- Returns title, metadata, and markdown-ish body. Summarise clearly and
link back with `[label](url)`.

View file

@ -0,0 +1,24 @@
<example>
user: "Check out https://dev.to/some-article"
→ scrape_webpage(url="https://dev.to/some-article")
(Respond with a structured analysis — key points, takeaways.)
</example>
<example>
user: "Read this article and summarize it for me: https://example.com/blog/ai-trends"
→ scrape_webpage(url="https://example.com/blog/ai-trends")
(Thorough summary using headings and bullets.)
</example>
<example>
user: (after discussing https://example.com/stats) "Can you get the live data from that page?"
→ scrape_webpage(url="https://example.com/stats")
(Always attempt scraping first. Never refuse before trying.)
</example>
<example>
user: "https://example.com/blog/weekend-recipes"
→ scrape_webpage(url="https://example.com/blog/weekend-recipes")
(When a user sends just a URL with no instructions, scrape it and provide
a concise summary.)
</example>

View file

@ -0,0 +1,19 @@
- `search_knowledge_base` — Search the user's own knowledge base (their
indexed documents, notes, files, and connected sources) with hybrid
semantic + keyword retrieval.
- This is your PRIMARY way to ground factual answers about the user's
workspace. The `<workspace_tree>` shows what files exist; this tool pulls
the actual relevant content. Call it BEFORE answering any question about
the user's documents, notes, or connected data — don't answer from the
tree alone or from memory.
- Each hit returns the document's virtual path, a relevance score, and the
matched snippets. The snippets are often enough to answer directly with a
citation.
- When you need a document's full text (not just snippets), delegate a read
to the `knowledge_base` specialist via `task`, passing the path from the
results.
- Args: `query` (focused; include concrete entities, acronyms, people,
projects, or terms), `top_k` (default 5, max 20).
- If nothing relevant comes back, tell the user you couldn't find it in
their workspace before offering to search the web or answer from general
knowledge.

View file

@ -0,0 +1,13 @@
<example>
user: "What did our Q3 planning doc say about hiring?"
→ search_knowledge_base(query="Q3 planning hiring headcount plan")
(Answer from the returned snippets with a citation; if you need the full
document, task the knowledge_base specialist with the returned path.)
</example>
<example>
user: "Summarize my notes on the Acme migration."
→ search_knowledge_base(query="Acme migration notes")
→ task(subagent_type="knowledge_base", description="Read <path> and return a
detailed summary of the Acme migration plan, risks, and timeline.")
</example>

View file

@ -43,9 +43,9 @@
like podcasts/videos), the operation did not happen — treat as
failure and surface that to the user verbatim, do not retry blindly.
2. **`task(web_crawler, …)`** — when a Receipt carries a `verifiable_url`
2. **`scrape_webpage`** — when a Receipt carries a `verifiable_url`
(Notion page URL, Slack permalink, Jira issue URL, Linear identifier
URL, etc.), you can crawl that URL and confirm the operation
URL, etc.), you can fetch that URL and confirm the operation
externally. Use this for high-stakes mutations the user explicitly
called out (e.g. "send the launch email to the whole team") or when
the subagent's self-report contradicts what the user expected.
@ -54,7 +54,7 @@
- `status="success"`: the mutation already committed in the backend.
If a `verifiable_url` is present and the request was high-stakes,
you may crawl it via `task(web_crawler, …)` to externally confirm. Otherwise trust
you may `scrape_webpage` it to externally confirm. Otherwise trust
the Receipt and tell the user it is done. Celery-backed deliverables
(podcasts, video presentations) also land here — the subagent
already waited for the worker to finish, so a `success` Receipt
@ -67,6 +67,6 @@
their backend before returning. If you ever do see a pending
Receipt, tell the user the work has been **kicked off** (quote the
`external_id` / `preview` so they can find it later), do not
crawl it, and do not re-dispatch the same
`scrape_webpage` it, and do not re-dispatch the same
`task(...)` call hoping it will be done "this time".
</verification>

Some files were not shown because too many files have changed in this diff Show more