@gmickel/gno 1.39.2 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/assets/skill/SKILL.md +17 -0
- package/assets/skill/cli-reference.md +48 -0
- package/assets/skill/mcp-reference.md +24 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.39.2.zip → gno-browser-clipper-v1.41.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.41.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +146 -7
- package/spec/db/schema.sql +17 -0
- package/spec/mcp.md +194 -0
- package/spec/output-schemas/memory-recall.schema.json +159 -0
- package/spec/output-schemas/memory-remember.schema.json +164 -0
- package/spec/output-schemas/status.schema.json +269 -54
- package/src/cli/commands/memory.ts +491 -0
- package/src/cli/commands/status.ts +23 -4
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +127 -0
- package/src/config/types.ts +7 -0
- package/src/core/audit-provenance.ts +91 -0
- package/src/core/audit-workspace.ts +17 -0
- package/src/core/memory-diagnostics.ts +144 -0
- package/src/core/memory-fence.ts +239 -0
- package/src/core/memory-recall.ts +269 -0
- package/src/core/memory-record.ts +435 -0
- package/src/core/memory-remember.ts +425 -0
- package/src/core/memory-types.ts +211 -0
- package/src/core/memory.ts +87 -0
- package/src/core/network-boundary-inventory.ts +10 -0
- package/src/ingestion/sync.ts +17 -0
- package/src/mcp/http-egress.ts +2 -0
- package/src/mcp/tools/index.ts +43 -0
- package/src/mcp/tools/memory-recall.ts +122 -0
- package/src/mcp/tools/memory-remember.ts +177 -0
- package/src/mcp/tools/memory-shared.ts +80 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +8 -0
- package/src/sdk/client.ts +94 -1
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +28 -0
- package/src/serve/CLAUDE.md +22 -2
- package/src/serve/clipper-security.ts +3 -17
- package/src/serve/public/components/pdf/PdfViewer.tsx +97 -19
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/globals.css +4 -0
- package/src/serve/public/hooks/use-pdf-document.ts +101 -37
- package/src/serve/public/hooks/use-pdf-pages.ts +324 -92
- package/src/serve/public/lib/pdf-transport.ts +13 -0
- package/src/serve/public/lib/pdf.ts +77 -10
- package/src/serve/public/lib/server-capabilities.ts +19 -0
- package/src/serve/public/pages/Ask.tsx +8 -9
- package/src/serve/public/pages/DocView.tsx +80 -18
- package/src/serve/public/pages/Search.tsx +8 -9
- package/src/serve/request-locality.ts +84 -0
- package/src/serve/routes/api.ts +255 -3
- package/src/serve/server.ts +189 -29
- package/src/store/migrations/027-memory-scopes.ts +37 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +127 -3
- package/src/store/types.ts +54 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.39.2.zip.sha256 +0 -1
|
@@ -54,6 +54,10 @@ import {
|
|
|
54
54
|
type QueryModeType,
|
|
55
55
|
type TagMode,
|
|
56
56
|
} from "../lib/retrieval-filters";
|
|
57
|
+
import {
|
|
58
|
+
fetchServerCapabilities,
|
|
59
|
+
type ServerCapabilities,
|
|
60
|
+
} from "../lib/server-capabilities";
|
|
57
61
|
import { cn } from "../lib/utils";
|
|
58
62
|
|
|
59
63
|
interface PageProps {
|
|
@@ -104,13 +108,6 @@ interface AskResponse {
|
|
|
104
108
|
verification?: AskVerification;
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
interface Capabilities {
|
|
108
|
-
bm25: boolean;
|
|
109
|
-
vector: boolean;
|
|
110
|
-
hybrid: boolean;
|
|
111
|
-
answer: boolean;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
111
|
interface ConversationEntry {
|
|
115
112
|
id: string;
|
|
116
113
|
query: string;
|
|
@@ -195,7 +192,9 @@ function renderAnswer(
|
|
|
195
192
|
export default function Ask({ navigate }: PageProps) {
|
|
196
193
|
const [query, setQuery] = useState("");
|
|
197
194
|
const [conversation, setConversation] = useState<ConversationEntry[]>([]);
|
|
198
|
-
const [capabilities, setCapabilities] = useState<
|
|
195
|
+
const [capabilities, setCapabilities] = useState<ServerCapabilities | null>(
|
|
196
|
+
null
|
|
197
|
+
);
|
|
199
198
|
const [collections, setCollections] = useState<Collection[]>([]);
|
|
200
199
|
const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
|
|
201
200
|
const [activePreset, setActivePreset] = useState("slim-tuned");
|
|
@@ -233,7 +232,7 @@ export default function Ask({ navigate }: PageProps) {
|
|
|
233
232
|
useEffect(() => {
|
|
234
233
|
async function bootstrap(): Promise<void> {
|
|
235
234
|
const [capsResult, collectionsResult, presetsResult] = await Promise.all([
|
|
236
|
-
|
|
235
|
+
fetchServerCapabilities(),
|
|
237
236
|
apiFetch<Collection[]>("/api/collections"),
|
|
238
237
|
apiFetch<PresetsResponse>("/api/presets"),
|
|
239
238
|
]);
|
|
@@ -94,6 +94,10 @@ import {
|
|
|
94
94
|
stripSectionTargetLinkParam,
|
|
95
95
|
type SectionLinkNoticeKind,
|
|
96
96
|
} from "../lib/section-links";
|
|
97
|
+
import {
|
|
98
|
+
fetchServerCapabilities,
|
|
99
|
+
type ServerCapabilities,
|
|
100
|
+
} from "../lib/server-capabilities";
|
|
97
101
|
import { subscribeWorkspaceActionRequest } from "../lib/workspace-events";
|
|
98
102
|
import {
|
|
99
103
|
FrontmatterDisplay,
|
|
@@ -361,6 +365,11 @@ function getParentPath(relPath: string): string {
|
|
|
361
365
|
|
|
362
366
|
export default function DocView({ navigate }: PageProps) {
|
|
363
367
|
const [doc, setDoc] = useState<DocData | null>(null);
|
|
368
|
+
// undefined = not answered yet (offer neither locality-dependent action),
|
|
369
|
+
// null = fetch failed (fail closed: treat the client as remote).
|
|
370
|
+
const [serverCapabilities, setServerCapabilities] = useState<
|
|
371
|
+
ServerCapabilities | null | undefined
|
|
372
|
+
>(undefined);
|
|
364
373
|
const [error, setError] = useState<string | null>(null);
|
|
365
374
|
const [loading, setLoading] = useState(true);
|
|
366
375
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
@@ -483,6 +492,20 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
483
492
|
});
|
|
484
493
|
}, [currentUri]);
|
|
485
494
|
|
|
495
|
+
// Server capabilities decide which host-local actions may render. A failed
|
|
496
|
+
// fetch leaves them null, which the view reads as a remote client.
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
let cancelled = false;
|
|
499
|
+
void fetchServerCapabilities().then(({ data }) => {
|
|
500
|
+
if (!cancelled) {
|
|
501
|
+
setServerCapabilities(data);
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
return () => {
|
|
505
|
+
cancelled = true;
|
|
506
|
+
};
|
|
507
|
+
}, []);
|
|
508
|
+
|
|
486
509
|
// Fetch document when URI changes
|
|
487
510
|
useEffect(() => {
|
|
488
511
|
loadDocument();
|
|
@@ -542,6 +565,11 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
542
565
|
|
|
543
566
|
const isPdf = Boolean(doc && isPdfDocument(doc.source));
|
|
544
567
|
|
|
568
|
+
const localClient = serverCapabilities?.localClient ?? false;
|
|
569
|
+
// Until /api/capabilities answers, a same-host user would otherwise see the
|
|
570
|
+
// remote "Open original" flash in and then swap to Reveal.
|
|
571
|
+
const localityKnown = serverCapabilities !== undefined;
|
|
572
|
+
|
|
545
573
|
// Spec predicate — evaluated per render, never from mime/ext.
|
|
546
574
|
const extractedTextAvailable = Boolean(doc && isExtractedTextAvailable(doc));
|
|
547
575
|
|
|
@@ -552,6 +580,16 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
552
580
|
return buildDocAssetUrl(doc.uri, doc.relPath);
|
|
553
581
|
}, [doc, isPdf]);
|
|
554
582
|
|
|
583
|
+
// Remote "Open original" target for any document that has a source file:
|
|
584
|
+
// /api/doc-asset serves any collection file inline, so this keeps the
|
|
585
|
+
// previous file:// scope (every read-only source) for remote clients.
|
|
586
|
+
const sourceAssetUrl = useMemo(() => {
|
|
587
|
+
if (!doc?.source.absPath) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
return buildDocAssetUrl(doc.uri, doc.relPath);
|
|
591
|
+
}, [doc]);
|
|
592
|
+
|
|
555
593
|
// Parse frontmatter for markdown files
|
|
556
594
|
const parsedContent = useMemo(() => {
|
|
557
595
|
if (!doc?.content || !isMarkdown) {
|
|
@@ -1690,10 +1728,10 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1690
1728
|
);
|
|
1691
1729
|
|
|
1692
1730
|
return (
|
|
1693
|
-
<div className="min-h-screen">
|
|
1731
|
+
<div className="min-h-screen min-w-0 overflow-x-clip">
|
|
1694
1732
|
{/* Header */}
|
|
1695
1733
|
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
1696
|
-
<div className="flex items-center gap-4 px-
|
|
1734
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-4 gap-y-2 px-4 py-4 sm:px-8">
|
|
1697
1735
|
{/* Home button - Scholarly Dusk brass accent */}
|
|
1698
1736
|
<Button
|
|
1699
1737
|
aria-label="Go to dashboard"
|
|
@@ -1730,8 +1768,14 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1730
1768
|
)}
|
|
1731
1769
|
{doc && (
|
|
1732
1770
|
<>
|
|
1733
|
-
<Separator
|
|
1734
|
-
|
|
1771
|
+
<Separator
|
|
1772
|
+
className="hidden h-6 sm:block"
|
|
1773
|
+
orientation="vertical"
|
|
1774
|
+
/>
|
|
1775
|
+
<div
|
|
1776
|
+
className="flex min-w-0 flex-wrap items-center gap-2"
|
|
1777
|
+
data-testid="doc-header-actions"
|
|
1778
|
+
>
|
|
1735
1779
|
{doc.capabilities.editable ? (
|
|
1736
1780
|
<>
|
|
1737
1781
|
<Button className="gap-1.5" onClick={handleEdit} size="sm">
|
|
@@ -1817,10 +1861,11 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1817
1861
|
)}
|
|
1818
1862
|
Export for gno.sh
|
|
1819
1863
|
</Button>
|
|
1820
|
-
{doc.source.absPath && (
|
|
1864
|
+
{localClient && doc.source.absPath && (
|
|
1821
1865
|
<>
|
|
1822
1866
|
<Button
|
|
1823
1867
|
className="gap-1.5"
|
|
1868
|
+
data-testid="doc-reveal"
|
|
1824
1869
|
onClick={() => {
|
|
1825
1870
|
void handleReveal();
|
|
1826
1871
|
}}
|
|
@@ -1832,6 +1877,7 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1832
1877
|
</Button>
|
|
1833
1878
|
<Button asChild size="sm" variant="outline">
|
|
1834
1879
|
<a
|
|
1880
|
+
data-testid="doc-open-original"
|
|
1835
1881
|
href={`file://${doc.source.absPath}`}
|
|
1836
1882
|
rel="noopener noreferrer"
|
|
1837
1883
|
target="_blank"
|
|
@@ -1842,21 +1888,37 @@ export default function DocView({ navigate }: PageProps) {
|
|
|
1842
1888
|
</Button>
|
|
1843
1889
|
</>
|
|
1844
1890
|
)}
|
|
1891
|
+
{localityKnown && !localClient && sourceAssetUrl && (
|
|
1892
|
+
<Button asChild size="sm" variant="outline">
|
|
1893
|
+
<a
|
|
1894
|
+
data-testid="doc-open-original"
|
|
1895
|
+
href={sourceAssetUrl}
|
|
1896
|
+
rel="noopener"
|
|
1897
|
+
target="_blank"
|
|
1898
|
+
>
|
|
1899
|
+
<SquareArrowOutUpRightIcon className="mr-1.5 size-4" />
|
|
1900
|
+
Open original
|
|
1901
|
+
</a>
|
|
1902
|
+
</Button>
|
|
1903
|
+
)}
|
|
1845
1904
|
</>
|
|
1846
1905
|
)}
|
|
1847
|
-
{
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1906
|
+
{localClient &&
|
|
1907
|
+
doc.capabilities.editable &&
|
|
1908
|
+
doc.source.absPath && (
|
|
1909
|
+
<Button
|
|
1910
|
+
className="gap-1.5"
|
|
1911
|
+
data-testid="doc-reveal"
|
|
1912
|
+
onClick={() => {
|
|
1913
|
+
void handleReveal();
|
|
1914
|
+
}}
|
|
1915
|
+
size="sm"
|
|
1916
|
+
variant="outline"
|
|
1917
|
+
>
|
|
1918
|
+
<FolderOpen className="size-4" />
|
|
1919
|
+
Reveal
|
|
1920
|
+
</Button>
|
|
1921
|
+
)}
|
|
1860
1922
|
{isPdf && pdfAssetUrl ? (
|
|
1861
1923
|
<Button
|
|
1862
1924
|
asChild
|
|
@@ -44,6 +44,10 @@ import {
|
|
|
44
44
|
type QueryModeType,
|
|
45
45
|
type TagMode,
|
|
46
46
|
} from "../lib/retrieval-filters";
|
|
47
|
+
import {
|
|
48
|
+
fetchServerCapabilities,
|
|
49
|
+
type ServerCapabilities,
|
|
50
|
+
} from "../lib/server-capabilities";
|
|
47
51
|
import { cn } from "../lib/utils";
|
|
48
52
|
import { AIModelSelector, TagFacets } from "./search-page-widgets";
|
|
49
53
|
|
|
@@ -121,13 +125,6 @@ interface SearchResponse {
|
|
|
121
125
|
};
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
interface Capabilities {
|
|
125
|
-
bm25: boolean;
|
|
126
|
-
vector: boolean;
|
|
127
|
-
hybrid: boolean;
|
|
128
|
-
answer: boolean;
|
|
129
|
-
}
|
|
130
|
-
|
|
131
128
|
interface Collection {
|
|
132
129
|
name: string;
|
|
133
130
|
}
|
|
@@ -157,7 +154,9 @@ export default function Search({ navigate }: PageProps) {
|
|
|
157
154
|
const [loading, setLoading] = useState(false);
|
|
158
155
|
const [error, setError] = useState<string | null>(null);
|
|
159
156
|
const [searched, setSearched] = useState(false);
|
|
160
|
-
const [capabilities, setCapabilities] = useState<
|
|
157
|
+
const [capabilities, setCapabilities] = useState<ServerCapabilities | null>(
|
|
158
|
+
null
|
|
159
|
+
);
|
|
161
160
|
const [collections, setCollections] = useState<Collection[]>([]);
|
|
162
161
|
const [activePreset, setActivePreset] = useState("slim-tuned");
|
|
163
162
|
|
|
@@ -257,7 +256,7 @@ export default function Search({ navigate }: PageProps) {
|
|
|
257
256
|
async function bootstrap(): Promise<void> {
|
|
258
257
|
const [capabilitiesResult, collectionsResult, presetsResult] =
|
|
259
258
|
await Promise.all([
|
|
260
|
-
|
|
259
|
+
fetchServerCapabilities(),
|
|
261
260
|
apiFetch<Collection[]>("/api/collections"),
|
|
262
261
|
apiFetch<PresetsResponse>("/api/presets"),
|
|
263
262
|
]);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request locality: is the caller a same-host browser?
|
|
3
|
+
*
|
|
4
|
+
* `gno serve` binds to loopback only, so every remote client reaches it through
|
|
5
|
+
* a same-host proxy or forwarder whose socket peer is loopback. A peer-only
|
|
6
|
+
* check would call those clients local; this rule also requires a loopback
|
|
7
|
+
* Host header and the absence of forwarding headers, and it fails closed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { HttpMcpPeerServer } from "../mcp/http-security";
|
|
11
|
+
|
|
12
|
+
export type RequestPeerServer = Pick<HttpMcpPeerServer, "requestIP">;
|
|
13
|
+
|
|
14
|
+
const FORWARDING_HEADERS = [
|
|
15
|
+
"forwarded",
|
|
16
|
+
"via",
|
|
17
|
+
"x-forwarded-for",
|
|
18
|
+
"x-forwarded-host",
|
|
19
|
+
"x-forwarded-proto",
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
const IPV4_MAPPED_PREFIX = "::ffff:";
|
|
23
|
+
const IPV4_OCTET = /^\d{1,3}$/;
|
|
24
|
+
const HOST_PORT = /^:\d{1,5}$/;
|
|
25
|
+
const LOOPBACK_IPV4_FIRST_OCTET = 127;
|
|
26
|
+
const MAX_OCTET = 255;
|
|
27
|
+
const IPV4_OCTET_COUNT = 4;
|
|
28
|
+
|
|
29
|
+
/** Loopback socket address: 127.0.0.0/8, ::1, or an IPv4-mapped form. */
|
|
30
|
+
export function isLoopbackAddress(address: string): boolean {
|
|
31
|
+
const normalized = address.toLowerCase();
|
|
32
|
+
if (normalized === "::1") return true;
|
|
33
|
+
const mapped = normalized.startsWith(IPV4_MAPPED_PREFIX)
|
|
34
|
+
? normalized.slice(IPV4_MAPPED_PREFIX.length)
|
|
35
|
+
: normalized;
|
|
36
|
+
const octets = mapped.split(".");
|
|
37
|
+
return (
|
|
38
|
+
octets.length === IPV4_OCTET_COUNT &&
|
|
39
|
+
octets.every((octet) => IPV4_OCTET.test(octet)) &&
|
|
40
|
+
Number(octets[0]) === LOOPBACK_IPV4_FIRST_OCTET &&
|
|
41
|
+
octets.every((octet) => Number(octet) <= MAX_OCTET)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Host part of a Host header (`name`, `name:port`, `[v6]`, `[v6]:port`). */
|
|
46
|
+
function hostHeaderName(host: string): string | null {
|
|
47
|
+
const trimmed = host.trim().toLowerCase();
|
|
48
|
+
if (trimmed.startsWith("[")) {
|
|
49
|
+
const end = trimmed.indexOf("]");
|
|
50
|
+
if (end === -1) return null;
|
|
51
|
+
const rest = trimmed.slice(end + 1);
|
|
52
|
+
if (rest !== "" && !HOST_PORT.test(rest)) return null;
|
|
53
|
+
return trimmed.slice(1, end) || null;
|
|
54
|
+
}
|
|
55
|
+
const [name, ...portParts] = trimmed.split(":");
|
|
56
|
+
if (portParts.length > 1) return null;
|
|
57
|
+
if (portParts.length === 1 && !HOST_PORT.test(`:${portParts[0]}`)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return name || null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Host header naming a loopback host: `localhost`, `127.x.x.x`, or `[::1]`. */
|
|
64
|
+
export function isLoopbackHostHeader(host: string | null): boolean {
|
|
65
|
+
if (!host) return false;
|
|
66
|
+
const name = hostHeaderName(host);
|
|
67
|
+
if (!name) return false;
|
|
68
|
+
return name === "localhost" || isLoopbackAddress(name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* True only when the socket peer is loopback, the Host header names a loopback
|
|
73
|
+
* host, and no forwarding header is present. Any other combination, a missing
|
|
74
|
+
* server, or an unknown peer yields false.
|
|
75
|
+
*/
|
|
76
|
+
export function isLocalClientRequest(
|
|
77
|
+
request: Request,
|
|
78
|
+
server: RequestPeerServer | undefined
|
|
79
|
+
): boolean {
|
|
80
|
+
const peer = server?.requestIP(request) ?? null;
|
|
81
|
+
if (!peer || !isLoopbackAddress(peer.address)) return false;
|
|
82
|
+
if (!isLoopbackHostHeader(request.headers.get("host"))) return false;
|
|
83
|
+
return !FORWARDING_HEADERS.some((header) => request.headers.has(header));
|
|
84
|
+
}
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -38,6 +38,7 @@ import type { StartJobError } from "../jobs";
|
|
|
38
38
|
import type { ResidentStatus } from "../status-model";
|
|
39
39
|
import type { CollectionWatchService } from "../watch-service";
|
|
40
40
|
|
|
41
|
+
import { getIndexDbPath } from "../../app/constants";
|
|
41
42
|
import { buildVerifiedAsk } from "../../app/verified-ask";
|
|
42
43
|
import { modelsPull } from "../../cli/commands/models/pull";
|
|
43
44
|
import {
|
|
@@ -80,6 +81,13 @@ import {
|
|
|
80
81
|
planCreateFolder,
|
|
81
82
|
planDuplicateRefactor,
|
|
82
83
|
} from "../../core/file-refactors";
|
|
84
|
+
import {
|
|
85
|
+
MemoryError,
|
|
86
|
+
type MemoryErrorCode,
|
|
87
|
+
MemoryService,
|
|
88
|
+
type RecallInput,
|
|
89
|
+
type RememberInput,
|
|
90
|
+
} from "../../core/memory";
|
|
83
91
|
import {
|
|
84
92
|
hasContentMutation,
|
|
85
93
|
recordContentMutation,
|
|
@@ -117,6 +125,7 @@ import {
|
|
|
117
125
|
validateTag,
|
|
118
126
|
} from "../../core/tags";
|
|
119
127
|
import { validateRelPath } from "../../core/validation";
|
|
128
|
+
import { writeLeasePath } from "../../core/write-lease";
|
|
120
129
|
import {
|
|
121
130
|
defaultSyncService,
|
|
122
131
|
type SyncResult,
|
|
@@ -177,6 +186,10 @@ import {
|
|
|
177
186
|
} from "../file-refactor-http";
|
|
178
187
|
import { analyzeImportPath } from "../import-preview";
|
|
179
188
|
import { getActiveJob, getJobStatus, startJob } from "../jobs";
|
|
189
|
+
import {
|
|
190
|
+
isLocalClientRequest,
|
|
191
|
+
type RequestPeerServer,
|
|
192
|
+
} from "../request-locality";
|
|
180
193
|
import {
|
|
181
194
|
requestRetrievalTraceId,
|
|
182
195
|
withRetrievalTraceHeader,
|
|
@@ -530,6 +543,12 @@ export interface CreateEditableCopyRequestBody {
|
|
|
530
543
|
uri?: string;
|
|
531
544
|
}
|
|
532
545
|
|
|
546
|
+
/** POST /api/memory/remember body: the shared core contract, verbatim. */
|
|
547
|
+
export interface MemoryRememberRequestBody extends RememberInput {}
|
|
548
|
+
|
|
549
|
+
/** POST /api/memory/recall body: the shared core contract, verbatim. */
|
|
550
|
+
export interface MemoryRecallRequestBody extends RecallInput {}
|
|
551
|
+
|
|
533
552
|
export interface PublishExportRequestBody {
|
|
534
553
|
encryptionPassphrase?: string;
|
|
535
554
|
slug?: string;
|
|
@@ -2179,6 +2198,53 @@ function parseSingleByteRange(
|
|
|
2179
2198
|
return { ok: true, start, end };
|
|
2180
2199
|
}
|
|
2181
2200
|
|
|
2201
|
+
/**
|
|
2202
|
+
* Revalidate-on-every-open: the browser asks once per open and reuses the
|
|
2203
|
+
* cached bytes on 304. Deliberately replaces fn-112's `no-store` (fn-136 R3).
|
|
2204
|
+
*/
|
|
2205
|
+
export const DOC_ASSET_CACHE_CONTROL = "private, max-age=0, must-revalidate";
|
|
2206
|
+
|
|
2207
|
+
const WEAK_ETAG_PREFIX = "W/";
|
|
2208
|
+
|
|
2209
|
+
/** Strong, quoted validator from file size and mtime (fn-136 R3). */
|
|
2210
|
+
export function docAssetEtag(file: {
|
|
2211
|
+
size: number;
|
|
2212
|
+
lastModified: number;
|
|
2213
|
+
}): string {
|
|
2214
|
+
return `"${file.size.toString(16)}-${file.lastModified.toString(16)}"`;
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* RFC 9110 §13.1.2 If-None-Match: `*` or any listed validator matching under
|
|
2219
|
+
* weak comparison (a `W/` prefix is ignored on the client's side).
|
|
2220
|
+
*
|
|
2221
|
+
* The list is split on a bare comma. Our validators (`"<size-hex>-<mtime-hex>"`)
|
|
2222
|
+
* never contain one; a foreign tag that does would only produce a false
|
|
2223
|
+
* negative (full body instead of 304), never a false match.
|
|
2224
|
+
*/
|
|
2225
|
+
export function etagMatches(
|
|
2226
|
+
ifNoneMatch: string | null | undefined,
|
|
2227
|
+
etag: string
|
|
2228
|
+
): boolean {
|
|
2229
|
+
if (!ifNoneMatch) {
|
|
2230
|
+
return false;
|
|
2231
|
+
}
|
|
2232
|
+
const trimmed = ifNoneMatch.trim();
|
|
2233
|
+
if (trimmed === "*") {
|
|
2234
|
+
return true;
|
|
2235
|
+
}
|
|
2236
|
+
for (const candidate of trimmed.split(",")) {
|
|
2237
|
+
const value = candidate.trim();
|
|
2238
|
+
const strong = value.startsWith(WEAK_ETAG_PREFIX)
|
|
2239
|
+
? value.slice(WEAK_ETAG_PREFIX.length)
|
|
2240
|
+
: value;
|
|
2241
|
+
if (strong === etag) {
|
|
2242
|
+
return true;
|
|
2243
|
+
}
|
|
2244
|
+
}
|
|
2245
|
+
return false;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2182
2248
|
/**
|
|
2183
2249
|
* GET|HEAD /api/doc-asset
|
|
2184
2250
|
* Query params:
|
|
@@ -2187,6 +2253,8 @@ function parseSingleByteRange(
|
|
|
2187
2253
|
*
|
|
2188
2254
|
* Supports single-range Range requests (206/416). Multi-range → 416 (I1-03).
|
|
2189
2255
|
* HEAD mirrors GET status/headers with empty body (I1-02).
|
|
2256
|
+
* Strong ETag from size + mtime; a matching If-None-Match answers 304 before
|
|
2257
|
+
* any Range handling, so full and ranged GETs revalidate alike (fn-136 R3).
|
|
2190
2258
|
*/
|
|
2191
2259
|
export async function handleDocAsset(
|
|
2192
2260
|
store: SqliteAdapter,
|
|
@@ -2269,13 +2337,24 @@ export async function handleDocAsset(
|
|
|
2269
2337
|
|
|
2270
2338
|
const isHead = (request?.method ?? "GET").toUpperCase() === "HEAD";
|
|
2271
2339
|
const filename = resolvedPath.split(/[\\/]/u).at(-1) ?? "document";
|
|
2340
|
+
const etag = docAssetEtag(file);
|
|
2272
2341
|
const headers = new Headers({
|
|
2273
2342
|
"Accept-Ranges": "bytes",
|
|
2274
|
-
"Cache-Control":
|
|
2343
|
+
"Cache-Control": DOC_ASSET_CACHE_CONTROL,
|
|
2275
2344
|
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
|
2276
2345
|
"Content-Type": file.type || "application/octet-stream",
|
|
2346
|
+
ETag: etag,
|
|
2277
2347
|
});
|
|
2278
2348
|
|
|
2349
|
+
if (etagMatches(request?.headers.get("If-None-Match"), etag)) {
|
|
2350
|
+
// 304 carries the revalidation headers of the 200 set (RFC 9110 §15.4.5)
|
|
2351
|
+
// and nothing that describes a body.
|
|
2352
|
+
const notModified = new Headers(headers);
|
|
2353
|
+
notModified.delete("Content-Disposition");
|
|
2354
|
+
notModified.delete("Content-Type");
|
|
2355
|
+
return new Response(null, { status: 304, headers: notModified });
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2279
2358
|
const rangeHeader = request?.headers.get("Range");
|
|
2280
2359
|
if (!rangeHeader) {
|
|
2281
2360
|
headers.set("Content-Length", String(file.size));
|
|
@@ -3114,6 +3193,12 @@ export async function handleCreateFolder(
|
|
|
3114
3193
|
}
|
|
3115
3194
|
}
|
|
3116
3195
|
|
|
3196
|
+
/**
|
|
3197
|
+
* POST /api/docs/:id/reveal
|
|
3198
|
+
* Opens the source file in the host's file manager. Only a local client may
|
|
3199
|
+
* open windows on the server host: a request judged remote by the locality
|
|
3200
|
+
* rule is refused before the document is resolved.
|
|
3201
|
+
*/
|
|
3117
3202
|
export async function handleRevealDoc(
|
|
3118
3203
|
ctxHolder: ContextHolder,
|
|
3119
3204
|
store: SqliteAdapter,
|
|
@@ -3121,8 +3206,17 @@ export async function handleRevealDoc(
|
|
|
3121
3206
|
req?: Request,
|
|
3122
3207
|
deps?: {
|
|
3123
3208
|
revealFilePath?: typeof revealFilePath;
|
|
3209
|
+
server?: RequestPeerServer;
|
|
3124
3210
|
}
|
|
3125
3211
|
): Promise<Response> {
|
|
3212
|
+
// Fail closed: no request (no peer to judge) is treated as remote.
|
|
3213
|
+
if (!req || !isLocalClientRequest(req, deps?.server)) {
|
|
3214
|
+
return errorResponse(
|
|
3215
|
+
"FORBIDDEN",
|
|
3216
|
+
"Reveal is only available to a local client",
|
|
3217
|
+
403
|
|
3218
|
+
);
|
|
3219
|
+
}
|
|
3126
3220
|
const docResult = await resolveDocumentReference(
|
|
3127
3221
|
store,
|
|
3128
3222
|
docId,
|
|
@@ -3594,6 +3688,158 @@ export async function handleCreateCapture(
|
|
|
3594
3688
|
}
|
|
3595
3689
|
}
|
|
3596
3690
|
|
|
3691
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3692
|
+
// Memory (remember / recall)
|
|
3693
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3694
|
+
|
|
3695
|
+
const HTTP_CREATED = 201;
|
|
3696
|
+
const HTTP_NOT_FOUND = 404;
|
|
3697
|
+
const HTTP_CONFLICT = 409;
|
|
3698
|
+
const HTTP_INTERNAL = 500;
|
|
3699
|
+
|
|
3700
|
+
/** HTTP status per stable memory error code; the code itself is the wire code. */
|
|
3701
|
+
const MEMORY_ERROR_STATUS: Readonly<Record<MemoryErrorCode, number>> = {
|
|
3702
|
+
MEMORY_TEXT_REQUIRED: 400,
|
|
3703
|
+
MEMORY_TEXT_TOO_LARGE: 400,
|
|
3704
|
+
MEMORY_QUERY_REQUIRED: 400,
|
|
3705
|
+
MEMORY_BUDGET_INVALID: 400,
|
|
3706
|
+
MEMORY_COLLECTION_REQUIRED: 400,
|
|
3707
|
+
MEMORY_COLLECTION_NOT_FOUND: HTTP_NOT_FOUND,
|
|
3708
|
+
MEMORY_COLLECTION_UNMANAGED: 400,
|
|
3709
|
+
MEMORY_SCOPES_REQUIRED: 400,
|
|
3710
|
+
MEMORY_SCOPES_INVALID: 400,
|
|
3711
|
+
MEMORY_IDENTITY_REQUIRED: 400,
|
|
3712
|
+
MEMORY_DECISION_INVALID: 400,
|
|
3713
|
+
MEMORY_PREDECESSOR_REQUIRED: 400,
|
|
3714
|
+
MEMORY_PREDECESSOR_NOT_FOUND: HTTP_NOT_FOUND,
|
|
3715
|
+
MEMORY_PREDECESSOR_HASH_MISMATCH: HTTP_CONFLICT,
|
|
3716
|
+
MEMORY_SUPERSEDE_CONFLICT: HTTP_CONFLICT,
|
|
3717
|
+
MEMORY_FENCED_REPLAY: 400,
|
|
3718
|
+
MEMORY_FENCED_DERIVED: 400,
|
|
3719
|
+
MEMORY_WRITE_LEASE_BUSY: HTTP_CONFLICT,
|
|
3720
|
+
MEMORY_SYNC_FAILED: HTTP_INTERNAL,
|
|
3721
|
+
MEMORY_SUPERSEDE_PROJECTION_FAILED: HTTP_INTERNAL,
|
|
3722
|
+
MEMORY_QUERY_FAILED: HTTP_INTERNAL,
|
|
3723
|
+
};
|
|
3724
|
+
|
|
3725
|
+
export interface MemoryRouteDeps {
|
|
3726
|
+
/** Shared `.mcp-write.lock` path; defaults to the resident index's lease. */
|
|
3727
|
+
lockPath?: string;
|
|
3728
|
+
lockWaitMs?: number;
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
function memoryErrorResponse(error: unknown, fallback: string): Response {
|
|
3732
|
+
if (error instanceof MemoryError) {
|
|
3733
|
+
return errorResponse(
|
|
3734
|
+
error.code,
|
|
3735
|
+
error.message,
|
|
3736
|
+
MEMORY_ERROR_STATUS[error.code]
|
|
3737
|
+
);
|
|
3738
|
+
}
|
|
3739
|
+
return errorResponse(
|
|
3740
|
+
"RUNTIME",
|
|
3741
|
+
`${fallback}: ${error instanceof Error ? error.message : String(error)}`,
|
|
3742
|
+
HTTP_INTERNAL
|
|
3743
|
+
);
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
async function readMemoryBody(
|
|
3747
|
+
req: Request
|
|
3748
|
+
): Promise<
|
|
3749
|
+
| { ok: true; body: Record<string, unknown> }
|
|
3750
|
+
| { ok: false; response: Response }
|
|
3751
|
+
> {
|
|
3752
|
+
let body: unknown;
|
|
3753
|
+
try {
|
|
3754
|
+
body = await req.json();
|
|
3755
|
+
} catch {
|
|
3756
|
+
return {
|
|
3757
|
+
ok: false,
|
|
3758
|
+
response: errorResponse("VALIDATION", "Invalid JSON body"),
|
|
3759
|
+
};
|
|
3760
|
+
}
|
|
3761
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
3762
|
+
return {
|
|
3763
|
+
ok: false,
|
|
3764
|
+
response: errorResponse(
|
|
3765
|
+
"VALIDATION",
|
|
3766
|
+
"Request body must be a JSON object"
|
|
3767
|
+
),
|
|
3768
|
+
};
|
|
3769
|
+
}
|
|
3770
|
+
return { ok: true, body: body as Record<string, unknown> };
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
/**
|
|
3774
|
+
* The service owns the shared write lease; this adapter never takes it.
|
|
3775
|
+
* Semantic matching/retrieval is enabled only when the resident context has
|
|
3776
|
+
* an embedding port (and a vector index for recall); otherwise the service
|
|
3777
|
+
* reports lexical-only mode in the result.
|
|
3778
|
+
*/
|
|
3779
|
+
function createMemoryService(
|
|
3780
|
+
ctxHolder: ContextHolder,
|
|
3781
|
+
store: SqliteAdapter,
|
|
3782
|
+
deps: MemoryRouteDeps
|
|
3783
|
+
): MemoryService {
|
|
3784
|
+
const ctx = ctxHolder.current;
|
|
3785
|
+
return new MemoryService({
|
|
3786
|
+
store,
|
|
3787
|
+
config: ctx.config,
|
|
3788
|
+
collections: ctx.config.collections,
|
|
3789
|
+
lockPath: deps.lockPath ?? writeLeasePath(getIndexDbPath(ctx.indexName)),
|
|
3790
|
+
lockWaitMs: deps.lockWaitMs,
|
|
3791
|
+
embedPort: ctx.embedPort,
|
|
3792
|
+
vectorIndex: ctx.vectorIndex,
|
|
3793
|
+
});
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
/**
|
|
3797
|
+
* POST /api/memory/remember
|
|
3798
|
+
* Store a fact (or propose candidates) in a memory-managed collection.
|
|
3799
|
+
* Returns 201 when a record was written, 200 otherwise.
|
|
3800
|
+
*/
|
|
3801
|
+
export async function handleMemoryRemember(
|
|
3802
|
+
ctxHolder: ContextHolder,
|
|
3803
|
+
store: SqliteAdapter,
|
|
3804
|
+
req: Request,
|
|
3805
|
+
deps: MemoryRouteDeps = {}
|
|
3806
|
+
): Promise<Response> {
|
|
3807
|
+
const parsed = await readMemoryBody(req);
|
|
3808
|
+
if (!parsed.ok) return parsed.response;
|
|
3809
|
+
try {
|
|
3810
|
+
const result = await createMemoryService(ctxHolder, store, deps).remember(
|
|
3811
|
+
parsed.body as unknown as MemoryRememberRequestBody
|
|
3812
|
+
);
|
|
3813
|
+
const wrote = result.outcome === "added" || result.outcome === "superseded";
|
|
3814
|
+
if (wrote) ctxHolder.markContentMutation?.();
|
|
3815
|
+
return jsonResponse(result, wrote ? HTTP_CREATED : 200);
|
|
3816
|
+
} catch (error) {
|
|
3817
|
+
return memoryErrorResponse(error, "Failed to remember");
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
/**
|
|
3822
|
+
* POST /api/memory/recall
|
|
3823
|
+
* Budgeted, cited recall of current facts in the caller's explicit scopes.
|
|
3824
|
+
*/
|
|
3825
|
+
export async function handleMemoryRecall(
|
|
3826
|
+
ctxHolder: ContextHolder,
|
|
3827
|
+
store: SqliteAdapter,
|
|
3828
|
+
req: Request,
|
|
3829
|
+
deps: MemoryRouteDeps = {}
|
|
3830
|
+
): Promise<Response> {
|
|
3831
|
+
const parsed = await readMemoryBody(req);
|
|
3832
|
+
if (!parsed.ok) return parsed.response;
|
|
3833
|
+
try {
|
|
3834
|
+
const result = await createMemoryService(ctxHolder, store, deps).recall(
|
|
3835
|
+
parsed.body as unknown as MemoryRecallRequestBody
|
|
3836
|
+
);
|
|
3837
|
+
return jsonResponse(result);
|
|
3838
|
+
} catch (error) {
|
|
3839
|
+
return memoryErrorResponse(error, "Failed to recall");
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3597
3843
|
/**
|
|
3598
3844
|
* POST /api/docs
|
|
3599
3845
|
* Create a new document in a collection.
|
|
@@ -4878,14 +5124,20 @@ export async function handleAsk(
|
|
|
4878
5124
|
|
|
4879
5125
|
/**
|
|
4880
5126
|
* GET /api/capabilities
|
|
4881
|
-
* Returns server capabilities (what features are available)
|
|
5127
|
+
* Returns server capabilities (what features are available) plus whether the
|
|
5128
|
+
* caller is a same-host client (see request-locality).
|
|
4882
5129
|
*/
|
|
4883
|
-
export function handleCapabilities(
|
|
5130
|
+
export function handleCapabilities(
|
|
5131
|
+
ctx: ServerContext,
|
|
5132
|
+
req: Request,
|
|
5133
|
+
server: RequestPeerServer | undefined
|
|
5134
|
+
): Response {
|
|
4884
5135
|
return jsonResponse({
|
|
4885
5136
|
bm25: ctx.capabilities.bm25,
|
|
4886
5137
|
vector: ctx.capabilities.vector,
|
|
4887
5138
|
hybrid: ctx.capabilities.hybrid,
|
|
4888
5139
|
answer: ctx.capabilities.answer,
|
|
5140
|
+
localClient: isLocalClientRequest(req, server),
|
|
4889
5141
|
});
|
|
4890
5142
|
}
|
|
4891
5143
|
|