Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 37 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,26 @@ Read this before writing any code, suggesting any feature, or making any archite

## What DumpIt Is

DumpIt is a personal knowledge vault with AI-powered retrieval. The core loop:
DumpIt is a personal knowledge vault with AI-powered retrieval, built toward a larger vision: **a portable layer of personal context that makes every AI interaction actually personal.**

The core loop today:
1. User saves something (a link, a note, a PDF)
2. User asks a question in natural language
3. DumpIt returns a cited answer grounded in what the user actually saved — not hallucinated from general knowledge

It is **not** a general-purpose chatbot. It is **not** a task manager. It is **not** an autonomous agent.
It is a save-and-query tool that happens to have excellent AI retrieval underneath.
The vision it's building toward: over time, what a user saves, reads, and asks about builds a real, living picture of them — their interests, work, how they think and talk, what they care about now versus before. Answers get sharper and more personal because the system actually knows the person asking, not because it's a smarter generic model.

**One-line positioning:** "Save anything. Ask anything. Get answers from your own knowledge — not a stranger's AI."
It is **not** a general-purpose chatbot. It is **not** a task manager. It is **not** an autonomous agent. It does **not** act on the user's behalf or impersonate them to others — it makes the user sharper, it does not replace them. This is a deliberate, permanent boundary, not a v1 limitation to relax later.

**One-line positioning (product):** "Save anything. Ask anything. Get answers from your own knowledge — not a stranger's AI."

**One-line positioning (vision):** "The layer of context that makes AI actually know you — once, portably, everywhere you use it."

---

## Who It's For

Primary persona: knowledge workers, developers, indie hackers, researchers, students — anyone who saves things online and later can't find or remember them.
Primary persona: knowledge workers, developers, indie hackers, researchers, students — anyone who saves things online and later can't find, remember, or get real value back from them.

The Canva/Claude parallel is intentional: **simple enough for a non-technical user to start in 60 seconds, deep enough for a developer to plug into their own stack via MCP or API.**

Expand Down Expand Up @@ -132,7 +136,8 @@ types/ Shared TypeScript types
| Mobile share sheet | 📋 Planned |
| MCP server (query vault from Claude/Cursor) | 📋 Planned — key differentiator |
| Dodo Payments integration | 🔨 In progress |
| Tone/preference profile (personalized answers) | 📋 Planned — v2 |
| Personal context / tone profile (shapes how answers are given) | 📋 Planned — v2, core to the vision |
| Portable context export (take your profile to other tools) | 📋 Planned — v3, long-term vision |

---

Expand All @@ -149,6 +154,15 @@ V1 is complete when:

---

## The Vision, Staged

- **v1 (now):** Capture + cited Q&A. Prove the core loop works and retains real users.
- **v2 (next):** Personal context/tone profile — DumpIt learns how the user writes, what they care about, and shapes *how it answers them* accordingly. This is the first real step toward "AI that knows you," and the layer that differentiates DumpIt from bare RAG tools.
- **v3 (later, only after v1/v2 prove retention):** Portability — the user's context isn't locked into DumpIt's own chat UI. It can be queried from Claude, Cursor, or other tools the user already uses (via MCP/API), so the value compounds wherever they work rather than requiring them to live inside DumpIt.
- **Never on the roadmap as currently scoped:** autonomous action on the user's behalf, or any feature that impersonates the user to other people. If this is ever revisited, it must be a deliberate, separate decision — not an incremental feature add.

---

## What Has Been Explicitly Ruled Out

These decisions were made deliberately. Do not reopen them without a strong reason.
Expand All @@ -157,12 +171,12 @@ These decisions were made deliberately. Do not reopen them without a strong reas
- Email/password auth was removed. Google Auth only. Rationale: simpler onboarding, zero password-reset overhead.

### Autonomous agents / "act on my behalf"
- DumpIt answers questions. It does not send emails, book things, or take irreversible actions on behalf of the user.
- This is a v1 and v2 constraint. If this ever changes it must be a deliberate, separate decision — not a feature added incrementally.
- DumpIt answers questions and shapes answers around who the user is. It does not send emails, book things, or take irreversible actions on behalf of the user.
- This applies at every stage of the roadmap, not just v1. Reversibility is the line: answering, even personalized answering, is low-risk if imperfect; acting is not.

### Digital clone / impersonation
- DumpIt does not generate content that impersonates the user to other people.
- The tone/preference profile (v2) shapes *how DumpIt answers the user*, not how it represents the user externally.
- DumpIt does not generate content that impersonates the user to other people, and never will under the current vision.
- The personal context/tone profile (v2) shapes *how DumpIt answers the user*, not how it represents the user externally. This is the core trust boundary of the whole product — do not blur it, even in service of a "wow" feature.

### OS-level integration
- Not a v1 or v2 item. The browser extension covers the majority of the capture use case with a fraction of the complexity.
Expand All @@ -185,11 +199,13 @@ These decisions were made deliberately. Do not reopen them without a strong reas
| Hermes + OMI + Obsidian | Power-user second brain stack | 30-45 min technical setup, not consumer-friendly |
| Notion AI / Obsidian | Note-taking + AI | Organization-first, not retrieval-first; no capture layer |
| Orchid | Consumer AI assistant via text | Acting-on-your-behalf positioning, not knowledge retrieval; receiving backlash |
| Claude / ChatGPT memory | Native AI memory | No capture layer, no extension, no email-forward; setup-heavy for real knowledge base use |
| Claude / ChatGPT memory | Native AI memory | Locked to one app, requires manual context-feeding, no capture layer; DumpIt's vision is to be the portable layer underneath all of these, not a competitor to any one of them |

**The gap DumpIt fills:** retrieval-first, consumer-simple, with a developer escape hatch (MCP/API). Nobody has shipped this cleanly.
**The gap DumpIt fills today:** retrieval-first, consumer-simple, with a developer escape hatch (MCP/API).

**The risk:** platform memory features (Claude Projects, ChatGPT Memory) are improving fast. The window for "it just works out of the box" as a moat is real but not permanent. Ship fast, get real retention data, and decide whether this is a 2-3 year window product or something with a deeper moat.
**The gap DumpIt is building toward:** a personal context layer that isn't locked into one AI app — portable, user-owned, and useful wherever the user chooses to work.

**The risk:** platform memory features (Claude Projects, ChatGPT Memory) are improving fast, and every major AI is racing toward "remembers you natively." The out-of-the-box simplicity moat is real but not permanent. Ship v1 fast, get real retention data, and let that data determine whether to push hard into the v2/v3 personalization-and-portability vision or treat this as a smaller, time-boxed product.

---

Expand Down Expand Up @@ -243,13 +259,21 @@ Payment processor: Dodo Payments (MoR — handles all global tax compliance auto

---

## Git Workflow

- Claude may push directly to feature branches without asking each time.
- `main` always goes through a PR — never push directly to `main`.

---

## What Good Looks Like

A session is successful if:
- The core save→ask→cited-answer loop works reliably on real saved content
- A new user can be saving and querying within 3 minutes of signing up
- No feature outside v1 scope was added
- No fake numbers appear anywhere in the product or on the landing page
- Any personalization work strictly shapes *answers to the user*, never generates content *as* the user to others
- Code follows the existing zinc/Space Grotesk design system
- All Gemini calls go through rate-limited API routes, never client-side

Expand Down
5 changes: 4 additions & 1 deletion app/RootLayoutClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { ReactNode, useEffect } from 'react'
import { initializeTelemetry } from './lib/telemetry'
import { ToastProvider } from './components/ui/Toast'

export function RootLayoutClient({ children }: { children: ReactNode }) {
useEffect(() => {
Expand All @@ -13,7 +14,9 @@ export function RootLayoutClient({ children }: { children: ReactNode }) {
return (
<ThemeProvider>
<AuthProvider>
{children}
<ToastProvider>
{children}
</ToastProvider>
</AuthProvider>
</ThemeProvider>
)
Expand Down
47 changes: 46 additions & 1 deletion app/api/_utils/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { createHash } from 'crypto';
import { DecodedIdToken } from 'firebase-admin/auth';
import { getServerAuth } from './firebaseAdmin';
import { getServerAuth, getServerFirestore } from './firebaseAdmin';

export const API_KEY_PREFIX = 'dk_live_';

export class AuthError extends Error {
status = 401;
Expand Down Expand Up @@ -41,3 +44,45 @@ export const requireAuth = async (request: NextRequest): Promise<DecodedIdToken>
export const isAuthError = (error: unknown): error is AuthError => (
error instanceof AuthError
);

export const hashApiKey = (rawKey: string): string => (
createHash('sha256').update(rawKey).digest('hex')
);

/**
* Resolves the caller's uid from either a Firebase ID token (dashboard/web app)
* or a long-lived `dk_live_...` API key (external API / MCP server clients).
* Firebase ID tokens expire in ~1hr and can't power a persistent MCP config,
* hence the separate API key path.
*/
export const requireAuthOrApiKey = async (request: NextRequest): Promise<{ uid: string }> => {
const token = getBearerToken(request);
if (!token) {
throw new AuthError();
}

if (token.startsWith(API_KEY_PREFIX)) {
const db = getServerFirestore();
const hash = hashApiKey(token);
const snapshot = await db.collection('api_keys').where('hash', '==', hash).limit(1).get();
if (snapshot.empty) {
throw new AuthError();
}

const keyDoc = snapshot.docs[0];
const uid = keyDoc.data().uid as string | undefined;
if (!uid) {
throw new AuthError();
}

keyDoc.ref.update({ last_used_at: new Date() }).catch(() => {});
return { uid };
}

try {
const decoded = await getServerAuth().verifyIdToken(token);
return { uid: decoded.uid };
} catch {
throw new AuthError();
}
};
4 changes: 2 additions & 2 deletions app/api/ai/ask/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { buildAnswerContext, normalizeSearchMode, searchResourceChunks } from '../../_utils/aiSearch';
import { isAuthError, requireAuth, unauthorizedResponse } from '../../_utils/auth';
import { isAuthError, requireAuthOrApiKey, unauthorizedResponse } from '../../_utils/auth';
import { generateAnswer } from '../../_utils/gemini';
import { checkAiRateLimit } from '../../_utils/rateLimit';
import { checkAiQueryLimit, incrementAiQueryCount } from '../../_utils/subscription';

export async function POST(request: NextRequest) {
try {
const authUser = await requireAuth(request);
const authUser = await requireAuthOrApiKey(request);

// Rate limit: 10 AI queries per minute per authenticated user
const rateLimitResponse = await checkAiRateLimit(request, authUser.uid);
Expand Down
4 changes: 2 additions & 2 deletions app/api/ai/search/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAuthError, requireAuth, unauthorizedResponse } from '../../_utils/auth';
import { isAuthError, requireAuthOrApiKey, unauthorizedResponse } from '../../_utils/auth';
import { normalizeSearchMode, searchResourceChunks } from '../../_utils/aiSearch';

export async function POST(request: NextRequest) {
try {
const authUser = await requireAuth(request);
const authUser = await requireAuthOrApiKey(request);
const { query, mode, limit } = await request.json();

if (!query || typeof query !== 'string') {
Expand Down
166 changes: 166 additions & 0 deletions app/api/extension/capture/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { NextRequest, NextResponse } from 'next/server';
import { isAuthError, requireAuth, unauthorizedResponse } from '../../_utils/auth';
import { getServerFirestore } from '../../_utils/firebaseAdmin';
import { getPreviewFromUrl } from '../../_utils/linkPreview';
import { checkAuthenticatedRateLimit } from '../../_utils/rateLimit';
import { indexResource } from '../../_utils/resourceIndexer';
import { checkResourceLimit } from '../../_utils/subscription';

// POST /api/extension/capture - Create a resource from the browser extension,
// preserving capture-specific metadata (selected text, canonical URL, source type)
export async function POST(request: NextRequest) {
try {
const authUser = await requireAuth(request);

// Rate limit: 60 authenticated requests per minute per user
const authRateLimitResponse = await checkAuthenticatedRateLimit(request, authUser.uid);
if (authRateLimitResponse) return authRateLimitResponse;

// Check Free tier resource limit
const resourceLimitCheck = await checkResourceLimit(authUser.uid);
if (resourceLimitCheck.isLimited) {
return NextResponse.json(
{
error: `Free tier resource limit (${resourceLimitCheck.max}) reached. Upgrade to Pro for unlimited resource saves.`,
code: 'UPGRADE_REQUIRED',
},
{ status: 403 }
);
}

const body = await request.json();
const {
title,
link,
note,
tag,
is_public,
collection_ids,
captured_text,
selected_text,
canonical_url,
source_type,
} = body;

// Validate required fields
if (!link && !note && !title && !captured_text) {
return NextResponse.json(
{ error: 'Missing content: at least a link, title, note, or text is required.' },
{ status: 400 }
);
}

// Validate link format if link is provided
if (link && !link.startsWith('http://') && !link.startsWith('https://')) {
return NextResponse.json(
{ error: 'Link must start with http:// or https://' },
{ status: 400 }
);
}

const db = getServerFirestore();

// Duplicate resource detection
if (link) {
const duplicateQuery = await db.collection('resources')
.where('user_id', '==', authUser.uid)
.where('link', '==', link)
.limit(1)
.get();

if (!duplicateQuery.empty) {
return NextResponse.json(
{ error: 'Duplicate resource: this link has already been saved to your vault.' },
{ status: 409 }
);
}
}

// If title or tag is missing, attempt to auto-enrich from link
let enrichedTitle = title || '';
let enrichedTag = tag || '';
if (link && (!enrichedTitle || !enrichedTag)) {
try {
const metadata = await getPreviewFromUrl(link);
if (!enrichedTitle && metadata.title) enrichedTitle = metadata.title;
if (!enrichedTag && metadata.suggestedTag) enrichedTag = metadata.suggestedTag;
} catch (err) {
console.warn('Failed to enrich link:', err);
}
}
if (!enrichedTitle) {
enrichedTitle = link ? 'Untitled Link' : 'Untitled Note';
}
if (!enrichedTag) {
enrichedTag = link ? 'Article' : 'Note';
}

const normalizedCollectionIds: string[] = Array.isArray(collection_ids)
? collection_ids.filter((id: unknown): id is string => typeof id === 'string' && id.trim().length > 0)
: [];

const resourceRef = db.collection('resources').doc();
const now = new Date();

const resourceData = {
user_id: authUser.uid,
title: enrichedTitle,
link: link || null,
note: note || null,
tag: enrichedTag || 'Note',
is_public: is_public ?? false,
collection_ids: normalizedCollectionIds,
captured_text: captured_text || null,
extension_metadata: {
selected_text: selected_text || null,
canonical_url: canonical_url || null,
source_type: source_type || 'chrome_extension',
},
index_status: 'pending',
index_error: null,
created_at: now,
updated_at: now,
};

let createdCollectionId: string | null = null;
await db.runTransaction(async (transaction) => {
normalizedCollectionIds.forEach((collectionId: string) => {
const membershipRef = db
.collection('users')
.doc(authUser.uid)
.collection('collections')
.doc(collectionId)
.collection('resources')
.doc(resourceRef.id);

transaction.set(membershipRef, {
resource_id: resourceRef.id,
added_at: now,
});
});

transaction.set(resourceRef, resourceData);
});

const indexing = await indexResource({ resourceId: resourceRef.id, uid: authUser.uid });

return NextResponse.json({
success: true,
message: 'Resource captured successfully',
resourceId: resourceRef.id,
createdCollectionId,
indexing,
});

} catch (error) {
if (isAuthError(error)) {
return unauthorizedResponse();
}

console.error('Error capturing extension resource:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Loading
Loading