Compare commits

...
Author SHA1 Message Date
shadowdaoandClaude Opus 5 063dc3ca00 chore: add MIT license
Prerequisite for making the repository public.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:35:44 -07:00
jknapp 0cb749806c Merge pull request 'feat: add Claude Code plugin with pre-registered Authentik OAuth client' (#8) from feat/claude-code-plugin into main 2026-07-27 03:21:17 +00:00
shadowdaoandClaude Opus 5 d7182820f5 feat: add Claude Code plugin with pre-registered Authentik OAuth client
Claude Code's .mcp.json now accepts an `oauth` block with a pre-registered
clientId, so the plugin no longer depends on RFC 7591 Dynamic Client
Registration (still unshipped in Authentik — goauthentik/authentik#8751,
milestoned for 2026.8.0). This lets users install shared-memory as a plugin
instead of running the `claude mcp add --client-id ...` one-liner by hand.

OIDC_CLIENT_ID_MCP is a Public PKCE client, so committing it is safe; no
secret is involved. callbackPort 33418 matches the documented one-liner and
is covered by the loopback redirect regex on the Authentik provider.

Verified: both manifests pass `claude plugin validate`, and a local-path
marketplace install on Claude Code 2.1.220 preserves the oauth block through
to the installed cache. Remote (git-sourced) marketplace install is still
untested — see anthropics/claude-ai-mcp#359.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:19:20 -07:00
jknapp cb1bd31de6 Merge pull request 'fix: repair memory-list project filter + add type-ahead project dropdown' (#7) from fix/memory-list-project-filter into main 2026-07-01 23:22:13 +00:00
shadowdaoandClaude Opus 4.8 8d51fbff7a fix: repair memory-list project filter + add type-ahead project dropdown
The memory list page threw a Next.js server-side exception whenever a
project filter was applied. The filter built a raw Drizzle `sql` fragment
that interpolated a JS string[] into `ANY(${accessibleIds}::uuid[])`,
which doesn't bind as a Postgres array literal — the same array-binding
bug class already fixed in #1 (commits 3019446, b3f7e60) for
memory.list/snippet.list. The search path avoided it by using the `pg`
tag; the plain list path did not.

Fix: resolve the typed project key to a single project id from the
user's accessible set (owned ∪ shared, owned winning on key collision to
match project.identify and the search path), then filter with a plain
`eq(memories.projectId, resolvedId)` — fully parameterized, no raw array
interpolation. Returns empty when the key matches no readable project.
This also makes the list view's collision semantics consistent with the
search view.

Feature: replace the plain "Project key…" text input with a type-ahead
combobox (_project-combobox.tsx) populated with the user's accessible
project keys, narrowing as they type; selecting a suggestion applies the
filter immediately. Free-typed keys still submit. getAccessibleProjects
is hoisted to the page and reused for both the dropdown options and the
list WHERE clause (no extra query on the list path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:17:47 -07:00
jknapp 4c1cff160d Merge pull request 'docs: add CLAUDE.md memory + snippet reuse policy' (#6) from docs/memory-snippet-reuse-policy into main 2026-06-18 15:45:32 +00:00
jknapp 73bac01b4e Merge branch 'main' into docs/memory-snippet-reuse-policy 2026-06-18 15:42:55 +00:00
shadowdaoandClaude Opus 4.8 43fd99c808 docs: add CLAUDE.md memory + snippet reuse policy
Document the on-demand memory workflow and snippet (boilerplate/template)
reuse workflow for agents working in this repo: query shared-memory only
when detail is needed (don't bulk-load), and browse snippet_list before
recreating known boilerplate, fetching by exact name with snippet_get.

Mirrors the user-scope `consult-memory-before-work` shared-memory snippet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:42:25 -07:00
jknapp 41149fe709 Merge pull request 'fix(compose): app healthcheck uses 127.0.0.1 not localhost (fixes false unhealthy)' (#5) from fix/app-healthcheck-ipv4 into main 2026-06-12 19:19:47 +00:00
shadowdaoandClaude Opus 4.8 af1a6c8165 fix(compose): app healthcheck uses 127.0.0.1 not localhost
Inside the app container localhost resolves to ::1 (IPv6) first, but the
Next.js standalone server listens only on 0.0.0.0 (IPv4). The healthcheck
probed http://localhost:3000/api/health and got Connection refused on ::1,
so the container reported unhealthy for weeks despite serving 200 on both
/ and /api/health. Switch the probe to 127.0.0.1 to match the bound iface.

The db and embedder healthchecks already avoid localhost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:16:25 -07:00
jknapp 2a94acddf3 Merge pull request 'feat: configurable CLI token TTL (CLI_TOKEN_TTL_DAYS, default 90d)' (#4) from feat/configurable-cli-token-ttl into main 2026-06-12 19:01:40 +00:00
jknapp 0c11869af8 Merge pull request 'fix: memory.list tag filter (#1) + Web UI shared-project visibility (#2)' (#3) from fix/list-tag-filter-and-shared-project-visibility into main 2026-06-12 19:01:33 +00:00
shadowdaoandClaude Opus 4.8 b3f7e6006e fix: snippet.list tag filter (same array-binding bug as memory.list) (#1)
Replace the raw `${snippets.tags} @> ${tags}::text[]` template with
Drizzle's arrayContains, matching the memory.list fix. The raw template
expanded the JS array into positional params, producing a malformed
array literal (one tag) / record-cast error (two tags) at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:46:44 -07:00
shadowdaoandClaude Opus 4.8 86433afe1f fix: list shared projects (owned ∪ shared) in Web UI project list (#2)
The Projects page filtered with eq(projects.userId, userId), so a user
with an rw (or ro) share on someone else's project never saw it in the
list — even though project.identify already returned {shared, access}
for the same project. Switch to getAccessibleProjects(userId,
groupNames) (owner ∪ group-shared, the same helper search/memories use)
and aggregate counts over that id set, and label non-owned rows with a
'shared · ro|rw' badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:38:22 -07:00
shadowdaoandClaude Opus 4.8 30194463b5 fix: bind memory.list tag filter as a single text[] param (#1)
memory.list built its tag filter with a raw sql template:
  sql`${memories.tags} @> ${tags}::text[]`
Drizzle expands a JS array embedded in a sql template into positional
params, so one tag produced `@> ($1)::text[]` (Postgres rejected the
bound string as a malformed array literal) and two tags produced
`@> ($1,$2)::text[]` (a record, hence "cannot cast type record to
text[]"). Switch to arrayContains(memories.tags, tags), which binds the
array as one text[] param via the column's toDriver and preserves the
"require ALL tags" (@>) semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 11:38:13 -07:00
11 changed files with 324 additions and 41 deletions
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "dnspegasus",
"description": "Self-hosted Claude Code plugins for the dnspegasus.net infrastructure.",
"owner": {
"name": "jknapp",
"url": "https://repo.anhonesthost.net/jknapp/shared-memory"
},
"plugins": [
{
"name": "shared-memory",
"source": "./plugin",
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by memory.dnspegasus.net and authenticated with Authentik OIDC."
}
]
}
+25
View File
@@ -0,0 +1,25 @@
# CLAUDE.md — shared-memory
Project key (for the shared-memory MCP): `shared-memory` (set by the `.shared-memory-project` marker at the repo root).
## Consulting memory before substantive work
There are TWO memory stores. The file-based memory (`MEMORY.md` + topic files) is auto-loaded into context every session. The **shared-memory MCP** (`mcp__shared-memory__*`) is NOT auto-loaded — you must query it. Query on demand when you need detail; do not bulk-load everything (that wastes context).
When a request draws on accumulated project knowledge — an architecture/development overview, debugging, planning, reviewing, or implementing a feature, or any question about how the system works — do this BEFORE answering or acting:
1. Use what's already in the auto-loaded `MEMORY.md` index.
2. ALSO check the shared-memory MCP: call `project_identify` once per session (resolves the key from `.shared-memory-project`), then `memory_search` with the task topic in natural language (a couple of queries if the task spans areas). Fetch full bodies with `memory_get` when a hit looks relevant.
3. Fold both sources into your answer; note when something came from saved memory.
## Reusing saved snippets (boilerplate / templates) before recreating work
Snippets (`mcp__shared-memory__snippet_*`) hold reusable artifacts — boilerplate, standard formats, checklists, established workflows. They are pull-only and, unlike memory, **NOT searchable**: `snippet_get` fetches by EXACT name, and the `description` shown by `snippet_list` is the ONLY discovery surface (tags are just for human browsing).
Before hand-writing standard/boilerplate code or re-deriving a known workflow, check whether a template already exists:
1. Call `snippet_list` once — it's cheap: it returns names + descriptions + tags, **no bodies**. Scan the descriptions for a match.
2. If one fits, `snippet_get <name>` to pull just that body and apply it, instead of recreating it from scratch.
3. When you produce a reusable artifact worth keeping, save it with `snippet_put` under a stable, predictable name (e.g. `boilerplate/<area>/<thing>`) and a concrete "when to use" `description` so a future agent can find it by scanning `snippet_list`.
Skip both checks only for trivial, self-contained requests (a quick edit, a one-off shell command, casual conversation) where prior project context can't matter. If the MCP server isn't connected in this session, proceed with file memory and say so.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Josh Knapp
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,151 @@
"use client";
import { useEffect, useId, useRef, useState } from "react";
const field =
"block w-full rounded-md bg-surface-1 border border-border " +
"text-fg placeholder:text-fg-subtle " +
"focus:border-accent-400 focus:outline-none " +
"disabled:opacity-50 transition-colors h-9 px-3 text-sm";
/**
* Type-ahead project filter. A text input whose value submits as `name`
* (default "project") via the enclosing GET form, plus a dropdown of the
* projects the user can read that narrows as they type. Selecting a
* suggestion fills the box and submits the form so the filter applies
* immediately; free text is still allowed (the input value is what
* submits), so an arbitrary key keeps working even if it isn't listed.
*/
export function ProjectCombobox({
name = "project",
defaultValue = "",
options,
className = "",
}: {
name?: string;
defaultValue?: string;
options: string[];
className?: string;
}) {
const [value, setValue] = useState(defaultValue);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listId = useId();
// Case-insensitive substring match. An empty box shows the full list so
// the control doubles as a "browse my projects" dropdown.
const q = value.trim().toLowerCase();
const matches = q
? options.filter((o) => o.toLowerCase().includes(q))
: options;
// Close when focus/click leaves the widget.
useEffect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
function commit(next: string) {
setValue(next);
setOpen(false);
// Write the DOM value synchronously before submitting: setValue only
// schedules a re-render (React batches it), so the input's serialized
// value would still be the pre-selection text when requestSubmit reads
// it. The upcoming render sets the same value, so there's no flicker.
if (inputRef.current) inputRef.current.value = next;
// requestSubmit fires a real submit (unlike form.submit()).
inputRef.current?.form?.requestSubmit();
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!open) {
setOpen(true);
setActive(0);
} else {
setActive((i) => Math.min(i + 1, matches.length - 1));
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
// Only intercept Enter to pick a highlighted suggestion; otherwise
// let it fall through and submit the form with the typed value.
if (open && matches[active]) {
e.preventDefault();
commit(matches[active]);
}
} else if (e.key === "Escape") {
if (open) {
e.preventDefault();
setOpen(false);
}
}
}
const showList = open && matches.length > 0;
return (
<div ref={rootRef} className={`relative ${className}`}>
<input
ref={inputRef}
type="text"
name={name}
value={value}
placeholder="Project key…"
autoComplete="off"
spellCheck={false}
role="combobox"
aria-expanded={showList}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
showList ? `${listId}-opt-${active}` : undefined
}
className={field}
onChange={(e) => {
setValue(e.target.value);
setOpen(true);
setActive(0);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
/>
{showList ? (
<ul
id={listId}
role="listbox"
className="absolute z-20 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-surface-1 py-1 shadow-lg"
>
{matches.map((opt, i) => (
<li
key={opt}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={i === active}
className={`cursor-pointer px-3 py-1.5 font-mono text-sm text-fg ${
i === active ? "bg-surface-2" : ""
}`}
// pointerdown (not click) so the choice registers before the
// input's blur/outside-pointerdown handler closes the list.
onPointerDown={(e) => {
e.preventDefault();
commit(opt);
}}
onMouseEnter={() => setActive(i)}
>
{opt}
</li>
))}
</ul>
) : null}
</div>
);
}
+33 -18
View File
@@ -1,10 +1,15 @@
import Link from "next/link"; import Link from "next/link";
import { and, desc, eq, isNull, inArray, or, sql } from "drizzle-orm"; import { and, desc, eq, isNull, inArray, or } from "drizzle-orm";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { memories, projects, projectShares } from "@/lib/db/schema"; import { memories, projects, projectShares } from "@/lib/db/schema";
import { searchMemories } from "@/lib/memories"; import { searchMemories } from "@/lib/memories";
import { getUserGroupNames, readableProjectIds } from "@/lib/access"; import {
getAccessibleProjects,
getUserGroupNames,
type AccessibleProject,
} from "@/lib/access";
import { ProjectCombobox } from "./_project-combobox";
import { Container, PageHeader } from "@/app/_components/ui/container"; import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card, CardBody } from "@/app/_components/ui/card"; import { Card, CardBody } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge"; import { Badge } from "@/app/_components/ui/badge";
@@ -50,12 +55,12 @@ async function fetchMemoriesByIds(
async function listRecent( async function listRecent(
userId: string, userId: string,
groupNames: string[], accessible: AccessibleProject[],
scope?: Scope, scope?: Scope,
project?: string, project?: string,
): Promise<MemoryRow[]> { ): Promise<MemoryRow[]> {
// Visibility: own rows OR rows in an accessible project. // Visibility: own rows OR rows in an accessible project.
const accessibleIds = await readableProjectIds(userId, groupNames); const accessibleIds = accessible.map((p) => p.projectId);
const visibility = const visibility =
accessibleIds.length > 0 accessibleIds.length > 0
? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds)) ? or(eq(memories.userId, userId), inArray(memories.projectId, accessibleIds))
@@ -63,17 +68,18 @@ async function listRecent(
const filters = [visibility!, isNull(memories.deletedAt)]; const filters = [visibility!, isNull(memories.deletedAt)];
if (scope) filters.push(eq(memories.scope, scope)); if (scope) filters.push(eq(memories.scope, scope));
if (project) { if (project) {
// Project filter — match the project key against any project the // Project filter — resolve the typed key against the projects the
// user can read (owned or shared). When the key matches none of // user can read (owned or shared), owned winning on a key collision
// those, return empty. // to match project.identify / the search path. Filtering by the
filters.push( // resolved id keeps the WHERE clause a plain equality — no raw-SQL
sql`${memories.projectId} IN ( // array binding (the source of the earlier memory.list crash). When
SELECT id FROM ${projects} // the key matches no accessible project, return empty.
WHERE ${projects.key} = ${project} const matches = accessible.filter((p) => p.projectKey === project);
AND (${projects.userId} = ${userId} const resolvedId =
OR ${projects.id} = ANY(${accessibleIds}::uuid[])) matches.find((p) => p.access === "owner")?.projectId ??
)`, matches[0]?.projectId;
); if (!resolvedId) return [];
filters.push(eq(memories.projectId, resolvedId));
} }
const rows = await db const rows = await db
.select({ .select({
@@ -120,6 +126,15 @@ export default async function MemoriesPage({
const scope = params.scope === "user" || params.scope === "project" ? params.scope : undefined; const scope = params.scope === "user" || params.scope === "project" ? params.scope : undefined;
const project = params.project?.trim() || undefined; const project = params.project?.trim() || undefined;
// Projects the user can read (owned shared). Reused both to build the
// list-path WHERE clause and to populate the project filter's type-ahead
// suggestions. Keys are deduped (a key can appear once per owned/shared
// project) and sorted for a stable dropdown order.
const accessible = await getAccessibleProjects(userId, groupNames);
const projectKeys = [...new Set(accessible.map((p) => p.projectKey))].sort(
(a, b) => a.localeCompare(b),
);
let rows: MemoryRow[] = []; let rows: MemoryRow[] = [];
let debug: { vec: number; fts: number; tag: number } | null = null; let debug: { vec: number; fts: number; tag: number } | null = null;
@@ -138,7 +153,7 @@ export default async function MemoriesPage({
}); });
debug = result.debug; debug = result.debug;
} else { } else {
rows = await listRecent(userId, groupNames, scope, project); rows = await listRecent(userId, accessible, scope, project);
} }
// Annotate which rows belong to projects that have any active share. // Annotate which rows belong to projects that have any active share.
@@ -182,10 +197,10 @@ export default async function MemoriesPage({
className="flex-1 min-w-[200px]" className="flex-1 min-w-[200px]"
/> />
<FilterSelect name="scope" value={scope} options={["", "project", "user"]} placeholder="Any scope" /> <FilterSelect name="scope" value={scope} options={["", "project", "user"]} placeholder="Any scope" />
<Input <ProjectCombobox
name="project" name="project"
placeholder="Project key…"
defaultValue={project ?? ""} defaultValue={project ?? ""}
options={projectKeys}
className="w-44" className="w-44"
/> />
<Button type="submit" variant="secondary">Apply</Button> <Button type="submit" variant="secondary">Apply</Button>
+35 -18
View File
@@ -1,8 +1,9 @@
import Link from "next/link"; 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 { auth } from "@/auth";
import { db } from "@/lib/db/client"; import { db } from "@/lib/db/client";
import { memories, projects } from "@/lib/db/schema"; import { memories, projects } from "@/lib/db/schema";
import { getAccessibleProjects, getUserGroupNames } from "@/lib/access";
import { Container, PageHeader } from "@/app/_components/ui/container"; import { Container, PageHeader } from "@/app/_components/ui/container";
import { Card } from "@/app/_components/ui/card"; import { Card } from "@/app/_components/ui/card";
import { Badge } from "@/app/_components/ui/badge"; import { Badge } from "@/app/_components/ui/badge";
@@ -13,24 +14,37 @@ export const dynamic = "force-dynamic";
export default async function ProjectsPage() { export default async function ProjectsPage() {
const session = await auth(); const session = await auth();
const userId = session!.user.id; const userId = session!.user.id;
const groupNames = await getUserGroupNames(userId);
const rows = await db // The project list is owned shared: projects the user owns PLUS
.select({ // projects shared with one of their groups (any access). Visibility was
id: projects.id, // previously owner-only (`eq(projects.userId, userId)`), which hid
key: projects.key, // projects another user shared in via project_shares even though
displayName: projects.displayName, // project.identify already reported them as {shared, access}.
createdAt: projects.createdAt, const accessible = await getAccessibleProjects(userId, groupNames);
memoryCount: sql<number>`count(${memories.id})::int`, const accessById = new Map(accessible.map((p) => [p.projectId, p.access]));
lastActivity: sql<Date | null>`max(${memories.createdAt})`, const accessibleIds = accessible.map((p) => p.projectId);
})
.from(projects) const rows =
.leftJoin( accessibleIds.length === 0
memories, ? []
and(eq(memories.projectId, projects.id), isNull(memories.deletedAt)), : await db
) .select({
.where(eq(projects.userId, userId)) id: projects.id,
.groupBy(projects.id) key: projects.key,
.orderBy(desc(sql`max(${memories.createdAt})`)); 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 ( return (
<Container className="pt-6"> <Container className="pt-6">
@@ -57,6 +71,9 @@ export default async function ProjectsPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-sm text-fg truncate">{p.key}</span> <span className="font-mono text-sm text-fg truncate">{p.key}</span>
<Badge>{p.memoryCount}</Badge> <Badge>{p.memoryCount}</Badge>
{accessById.get(p.id) !== "owner" ? (
<Badge tone="accent">shared · {accessById.get(p.id)}</Badge>
) : null}
</div> </div>
{p.displayName && p.displayName !== p.key ? ( {p.displayName && p.displayName !== p.key ? (
<div className="text-xs text-fg-muted truncate mt-0.5">{p.displayName}</div> <div className="text-xs text-fg-muted truncate mt-0.5">{p.displayName}</div>
+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 { db } from "@/lib/db/client";
import { import {
memories, memories,
@@ -465,7 +465,13 @@ const memoryList: ToolDef = {
} }
if (parsed.data.tags && parsed.data.tags.length > 0) { 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 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 { db } from "@/lib/db/client";
import { snippets, projects } from "@/lib/db/schema"; import { snippets, projects } from "@/lib/db/schema";
import type { Snippet } from "@/lib/db/schema"; import type { Snippet } from "@/lib/db/schema";
@@ -349,7 +349,13 @@ export async function listSnippets(
} }
if (tags && tags.length > 0) { 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 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. # 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" - "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000"
healthcheck: 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 interval: 15s
timeout: 5s timeout: 5s
retries: 5 retries: 5
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
"name": "shared-memory",
"version": "0.1.0",
"description": "Shared persistent memory and snippet library for Claude Code sessions, backed by memory.dnspegasus.net and authenticated with Authentik OIDC.",
"author": {
"name": "jknapp"
},
"homepage": "https://memory.dnspegasus.net"
}
+12
View File
@@ -0,0 +1,12 @@
{
"mcpServers": {
"shared-memory": {
"type": "http",
"url": "https://memory.dnspegasus.net/api/mcp",
"oauth": {
"clientId": "5rkRS3rJhn3Ci9swWkxYMIrZ9OggsjOGy3cIOhYY",
"callbackPort": 33418
}
}
}
}