docs: add CLAUDE.md memory + snippet reuse policy #6

Merged
jknapp merged 2 commits from docs/memory-snippet-reuse-policy into main 2026-06-18 15:45:32 +00:00
6 changed files with 81 additions and 24 deletions
Showing only changes of commit 73bac01b4e - Show all commits
+5
View File
@@ -70,6 +70,11 @@ NEXTAUTH_SECRET=replace-me-with-32-bytes-of-random
# -----------------------------------------------------------------------------
CLI_TOKEN_SECRET=replace-me-with-32-bytes-of-random
# Lifetime (in days) of newly minted CLI tokens. Positive integer; unset or
# invalid values fall back to 90. Only affects tokens minted after this is set —
# already-issued tokens keep their original expiry.
# CLI_TOKEN_TTL_DAYS=90
# -----------------------------------------------------------------------------
# App
# -----------------------------------------------------------------------------
+35 -18
View File
@@ -1,8 +1,9 @@
import Link from "next/link";
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import { and, desc, eq, inArray, isNull, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db/client";
import { memories, projects } from "@/lib/db/schema";
import { getAccessibleProjects, getUserGroupNames } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge";
@@ -13,24 +14,37 @@ export const dynamic = "force-dynamic";
export default async function ProjectsPage() {
const session = await auth();
const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
const rows = await db
.select({
id: projects.id,
key: projects.key,
displayName: projects.displayName,
createdAt: projects.createdAt,
memoryCount: sql<number>`count(${memories.id})::int`,
lastActivity: sql<Date | null>`max(${memories.createdAt})`,
})
.from(projects)
.leftJoin(
memories,
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)),
)
.where(eq(projects.userId, userId))
.groupBy(projects.id)
.orderBy(desc(sql`max(${memories.createdAt})`));
// The project list is owned shared: projects the user owns PLUS
// projects shared with one of their groups (any access). Visibility was
// previously owner-only (`eq(projects.userId, userId)`), which hid
// projects another user shared in via project_shares even though
// project.identify already reported them as {shared, access}.
const accessible = await getAccessibleProjects(userId, groupNames);
const accessById = new Map(accessible.map((p) => [p.projectId, p.access]));
const accessibleIds = accessible.map((p) => p.projectId);
const rows =
accessibleIds.length === 0
? []
: await db
.select({
id: projects.id,
key: projects.key,
displayName: projects.displayName,
createdAt: projects.createdAt,
memoryCount: sql<number>`count(${memories.id})::int`,
lastActivity: sql<Date | null>`max(${memories.createdAt})`,
})
.from(projects)
.leftJoin(
memories,
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)),
)
.where(inArray(projects.id, accessibleIds))
.groupBy(projects.id)
.orderBy(desc(sql`max(${memories.createdAt})`));
return (
<Container className="pt-6">
@@ -57,6 +71,9 @@ export default async function ProjectsPage() {
<div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg truncate">{p.key}</span>
<Badge>{p.memoryCount}</Badge>
{accessById.get(p.id) !== "owner" ? (
<Badge tone="accent">shared · {accessById.get(p.id)}</Badge>
) : null}
</div>
{p.displayName && p.displayName !== p.key ? (
<div className="text-xs text-fg-muted truncate mt-0.5">{p.displayName}</div>
+20 -1
View File
@@ -29,7 +29,26 @@ import { cliTokens } from "@/lib/db/schema";
export const CLI_TOKEN_KID = "cli-v1";
export const CLI_TOKEN_ISSUER = "shared-memory:cli";
export const CLI_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days
// Default lifetime for newly minted CLI tokens. Overridable via the
// CLI_TOKEN_TTL_DAYS env var (must be a positive integer number of days);
// anything unset/invalid falls back to this default. Only affects tokens
// minted from now on — already-issued tokens keep their original `exp`.
const DEFAULT_CLI_TOKEN_TTL_DAYS = 90;
function cliTokenTtlSeconds(): number {
const raw = process.env.CLI_TOKEN_TTL_DAYS;
let days = DEFAULT_CLI_TOKEN_TTL_DAYS;
if (raw !== undefined && raw.trim() !== "") {
const parsed = Number(raw);
if (Number.isInteger(parsed) && parsed > 0) {
days = parsed;
}
}
return days * 60 * 60 * 24;
}
export const CLI_TOKEN_TTL_SECONDS = cliTokenTtlSeconds();
function secret(): Uint8Array {
return new TextEncoder().encode(env().CLI_TOKEN_SECRET);
+8 -2
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, arrayContains, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { db } from "@/lib/db/client";
import {
memories,
@@ -465,7 +465,13 @@ const memoryList: ToolDef = {
}
if (parsed.data.tags && parsed.data.tags.length > 0) {
where.push(sql`${memories.tags} @> ${parsed.data.tags}::text[]`);
// Require ALL listed tags (array containment). Use Drizzle's
// arrayContains so the JS array binds as a single text[] param
// (via the column's toDriver) rather than being expanded into
// positional params — a raw `${tags}::text[]` template expands to
// `($1)::text[]` / `($1,$2)::text[]`, which Postgres rejects as a
// malformed array literal / record cast.
where.push(arrayContains(memories.tags, parsed.data.tags));
}
const rows = await db
+8 -2
View File
@@ -1,4 +1,4 @@
import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm";
import { and, arrayContains, desc, eq, inArray, isNull, or } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { snippets, projects } from "@/lib/db/schema";
import type { Snippet } from "@/lib/db/schema";
@@ -349,7 +349,13 @@ export async function listSnippets(
}
if (tags && tags.length > 0) {
where.push(sql`${snippets.tags} @> ${tags}::text[]`);
// Require ALL listed tags (array containment). Use Drizzle's
// arrayContains so the JS array binds as a single text[] param
// (via the column's toDriver) rather than being expanded into
// positional params — a raw `${tags}::text[]` template expands to
// `($1)::text[]` / `($1,$2)::text[]`, which Postgres rejects as a
// malformed array literal / record cast.
where.push(arrayContains(snippets.tags, tags));
}
const rows = await db
+5 -1
View File
@@ -129,7 +129,11 @@ services:
# but you can bind to 127.0.0.1 only by setting APP_BIND=127.0.0.1.
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3000/api/health || exit 1"]
# Use 127.0.0.1, not localhost: inside the container localhost resolves
# to ::1 (IPv6) first, but the Next.js standalone server listens only on
# 0.0.0.0 (IPv4), so a localhost probe gets "Connection refused" and the
# container is reported unhealthy even though the app serves fine.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5