···
1
1
+
node_modules/
2
2
+
.wrangler/
3
3
+
.zig-cache/
4
4
+
zig-out/
5
5
+
zig-cache/
6
6
+
zig-pkg/
7
7
+
8
8
+
.env
9
9
+
.dev.vars
10
10
+
*.local
11
11
+
12
12
+
.DS_Store
13
13
+
__pycache__/
14
14
+
*.pyc
15
15
+
16
16
+
corpus.ndjson
17
17
+
manifest.json
18
18
+
search.html
···
1
1
+
# Pensieve Agent Notes
2
2
+
3
3
+
Pensieve is a Cloudflare Worker app for public ATProto memory search. Work from
4
4
+
this repository root.
5
5
+
6
6
+
## Current Shape
7
7
+
8
8
+
- `src/worker.js` is the live app: Worker routes, indexing pipeline, artifact
9
9
+
extraction, frontend, and client-side behavior are all in one file.
10
10
+
- `src/*.zig` is an earlier/prototype extractor that walks repo CARs and emits
11
11
+
NDJSON. Keep it building, but do not assume it is the deployed path.
12
12
+
- `scripts/` contains smoke/demo helpers. They are not part of the Worker
13
13
+
runtime.
14
14
+
15
15
+
## Commands
16
16
+
17
17
+
```sh
18
18
+
npm install
19
19
+
node --check src/worker.js
20
20
+
npm run dry-run
21
21
+
npm run deploy
22
22
+
zig build test
23
23
+
```
24
24
+
25
25
+
Use `npm run dry-run` before deploys. For frontend changes, use Playwright or a
26
26
+
browser to inspect desktop and mobile layouts against the live or local Worker.
27
27
+
28
28
+
## Secrets
29
29
+
30
30
+
Never print or commit secret values. The Worker needs:
31
31
+
32
32
+
- `VOYAGE_API_KEY`
33
33
+
- `TURBOPUFFER_API_KEY`
34
34
+
35
35
+
Store deployed values with Cloudflare Worker secrets (`wrangler secret put`).
36
36
+
For local work, prefer `op run` or `.dev.vars`; `.env` and `.dev.vars` are
37
37
+
ignored.
38
38
+
39
39
+
## Product Constraints
40
40
+
41
41
+
- The user-facing app should feel like Pensieve, not an ATProto debugging tool.
42
42
+
- Avoid hardcoding app-specific collection allowlists as the main semantic
43
43
+
strategy. The important subproblem is extracting a useful artifact envelope
44
44
+
from arbitrary records.
45
45
+
- Native links beat PDSLS. Use PDSLS as "details" when a better destination is
46
46
+
available.
47
47
+
- The handle input uses the sibling typeahead service at
48
48
+
`https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead`.
49
49
+
50
50
+
## Indexing Notes
51
51
+
52
52
+
Actor indexes live in Turbopuffer namespaces named:
53
53
+
54
54
+
```text
55
55
+
${TURBOPUFFER_NAMESPACE_PREFIX}-${did with punctuation replaced}
56
56
+
```
57
57
+
58
58
+
`/api/search` uses existing namespaces. `/api/update` deletes and rebuilds the
59
59
+
actor namespace, streams progress, then searches the fresh index.
60
60
+
61
61
+
## Before Handing Back
62
62
+
63
63
+
- Run `node --check src/worker.js`.
64
64
+
- Run `npm run dry-run`.
65
65
+
- If UI changed, check at least one desktop and one mobile viewport.
66
66
+
- If deployed, include the Worker URL and the deployed version ID when useful.
···
1
1
+
# Pensieve
2
2
+
3
3
+
Pensieve is public memory search for ATProto repos. Give it a handle and a
4
4
+
half-remembered phrase; it walks that person's public repo, extracts semantic
5
5
+
artifacts from arbitrary record shapes, embeds them with Voyage, stores them in
6
6
+
Turbopuffer, and returns a small human-readable digest with links back to the
7
7
+
native thing when Pensieve can infer one.
8
8
+
9
9
+
Live Worker:
10
10
+
11
11
+
```text
12
12
+
https://pensieve.n8-3e9.workers.dev
13
13
+
```
14
14
+
15
15
+
## What Exists
16
16
+
17
17
+
- A Cloudflare Worker in `src/worker.js`.
18
18
+
- A single-page frontend rendered by the Worker.
19
19
+
- A Zig prototype in `src/*.zig` for CAR walking and NDJSON extraction.
20
20
+
- Small Python smoke/demo scripts in `scripts/`.
21
21
+
22
22
+
The production-ish MVP is the Worker. The Zig code is useful reference and a
23
23
+
future extraction path, but the deployed app currently uses `@atcute/repo` in
24
24
+
the Worker to walk repo CARs.
25
25
+
26
26
+
## How It Works
27
27
+
28
28
+
Search is two-stage:
29
29
+
30
30
+
1. `/api/search` resolves the actor, embeds the query, and searches that actor's
31
31
+
Turbopuffer namespace.
32
32
+
2. If the namespace is missing, the frontend calls `/api/update`, which streams
33
33
+
progress while Pensieve fetches `com.atproto.sync.getRepo`, walks the CAR,
34
34
+
infers artifacts, embeds up to `INDEX_LIMIT` records, writes them to
35
35
+
Turbopuffer, and then runs the query.
36
36
+
37
37
+
Second and later searches for the same actor are fast because they reuse the
38
38
+
existing Turbopuffer namespace. Re-indexing currently happens only through
39
39
+
`/api/update`, which deletes and rebuilds that actor namespace.
40
40
+
41
41
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the data flow and
42
42
+
[docs/OPERATIONS.md](docs/OPERATIONS.md) for deployment and secrets.
43
43
+
44
44
+
## Development
45
45
+
46
46
+
```sh
47
47
+
npm install
48
48
+
npm run dev
49
49
+
```
50
50
+
51
51
+
Useful checks:
52
52
+
53
53
+
```sh
54
54
+
node --check src/worker.js
55
55
+
npm run dry-run
56
56
+
```
57
57
+
58
58
+
Deploy:
59
59
+
60
60
+
```sh
61
61
+
npm run deploy
62
62
+
```
63
63
+
64
64
+
Zig prototype:
65
65
+
66
66
+
```sh
67
67
+
zig build test
68
68
+
zig build run -- extract --actor zzstoatzz.io --out corpus.ndjson
69
69
+
zig build run -- demo --actor zzstoatzz.io --media --dir /tmp/pensieve-demo
70
70
+
```
71
71
+
72
72
+
## Secrets
73
73
+
74
74
+
Do not commit secrets. The deployed Worker expects these Cloudflare Worker
75
75
+
secrets:
76
76
+
77
77
+
- `VOYAGE_API_KEY`
78
78
+
- `TURBOPUFFER_API_KEY`
79
79
+
80
80
+
The non-secret namespace prefix lives in `wrangler.jsonc` as
81
81
+
`TURBOPUFFER_NAMESPACE_PREFIX`.
82
82
+
83
83
+
Local development can use `wrangler secret put`, `.dev.vars`, or `op run` with
84
84
+
1Password. The local `.env` / `.dev.vars` files are intentionally ignored.
85
85
+
86
86
+
## Project Map
87
87
+
88
88
+
```text
89
89
+
src/worker.js Cloudflare Worker, API, indexing, frontend HTML/CSS/JS
90
90
+
src/*.zig Zig CAR extraction prototype
91
91
+
scripts/tpuf_demo.py Python smoke script for NDJSON -> Voyage -> Turbopuffer
92
92
+
wrangler.jsonc Cloudflare Worker config
93
93
+
docs/ Architecture and operations notes
94
94
+
```
95
95
+
96
96
+
## Contributing Notes
97
97
+
98
98
+
- Keep product copy non-jargony. Avoid surfacing "CAR", "repo", "collection",
99
99
+
or implementation terms in the UI unless they are hidden behind an inspection
100
100
+
affordance.
101
101
+
- Prefer deterministic artifact extraction before adding model calls.
102
102
+
- Keep PDSLS as a details/inspection fallback, not the primary destination when
103
103
+
a native artifact URL is known.
104
104
+
- Verify UI changes with a browser at desktop and mobile widths.
···
1
1
+
const std = @import("std");
2
2
+
3
3
+
pub fn build(b: *std.Build) void {
4
4
+
const target = b.standardTargetOptions(.{});
5
5
+
const optimize = b.standardOptimizeOption(.{});
6
6
+
7
7
+
const zat = b.dependency("zat", .{ .target = target, .optimize = optimize });
8
8
+
9
9
+
const pensieve_mod = b.createModule(.{
10
10
+
.root_source_file = b.path("src/root.zig"),
11
11
+
.target = target,
12
12
+
.optimize = optimize,
13
13
+
.imports = &.{
14
14
+
.{ .name = "zat", .module = zat.module("zat") },
15
15
+
},
16
16
+
});
17
17
+
18
18
+
const exe_mod = b.createModule(.{
19
19
+
.root_source_file = b.path("src/main.zig"),
20
20
+
.target = target,
21
21
+
.optimize = optimize,
22
22
+
.imports = &.{
23
23
+
.{ .name = "pensieve", .module = pensieve_mod },
24
24
+
.{ .name = "zat", .module = zat.module("zat") },
25
25
+
},
26
26
+
});
27
27
+
28
28
+
const exe = b.addExecutable(.{
29
29
+
.name = "pensieve",
30
30
+
.root_module = exe_mod,
31
31
+
});
32
32
+
b.installArtifact(exe);
33
33
+
34
34
+
const run = b.addRunArtifact(exe);
35
35
+
if (b.args) |args| run.addArgs(args);
36
36
+
b.step("run", "Run pensieve").dependOn(&run.step);
37
37
+
38
38
+
const tests = b.addTest(.{ .root_module = pensieve_mod });
39
39
+
const run_tests = b.addRunArtifact(tests);
40
40
+
b.step("test", "Run tests").dependOn(&run_tests.step);
41
41
+
}
···
1
1
+
.{
2
2
+
.name = .pensieve,
3
3
+
.version = "0.0.1",
4
4
+
.fingerprint = 0xd0ff236ba064c0dd,
5
5
+
.minimum_zig_version = "0.16.0",
6
6
+
.dependencies = .{
7
7
+
.zat = .{
8
8
+
.url = "https://tangled.org/zat.dev/zat/archive/v0.3.8.tar.gz",
9
9
+
.hash = "zat-0.3.8-5PuC7glgCgCVn18R0k6AuXiNok-jjqDRGgaBFj5JsI_N",
10
10
+
},
11
11
+
},
12
12
+
.paths = .{
13
13
+
"build.zig",
14
14
+
"build.zig.zon",
15
15
+
"src",
16
16
+
"README.md",
17
17
+
},
18
18
+
}
···
1
1
+
# Architecture
2
2
+
3
3
+
Pensieve has one deployed runtime: a Cloudflare Worker in `src/worker.js`.
4
4
+
5
5
+
```text
6
6
+
browser
7
7
+
|
8
8
+
| /api/actors -> typeahead.waow.tech
9
9
+
| /api/search -> resolve actor -> Voyage query embedding -> Turbopuffer
10
10
+
| /api/update -> resolve actor -> PDS getRepo -> CAR walk -> Voyage docs -> Turbopuffer
11
11
+
v
12
12
+
Cloudflare Worker
13
13
+
```
14
14
+
15
15
+
## Request Flow
16
16
+
17
17
+
### `/`
18
18
+
19
19
+
Returns the single-page app. The frontend is inline HTML/CSS/JS generated by
20
20
+
`renderIndex()`.
21
21
+
22
22
+
### `/api/actors`
23
23
+
24
24
+
Proxies handle search to the sibling typeahead service:
25
25
+
26
26
+
```text
27
27
+
https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead
28
28
+
```
29
29
+
30
30
+
This keeps handle selection consistent with the user's other ATProto tools.
31
31
+
32
32
+
### `/api/search`
33
33
+
34
34
+
1. Normalizes the actor handle.
35
35
+
2. Resolves it to DID + PDS with `@atcute/identity-resolver`.
36
36
+
3. Embeds the query with Voyage.
37
37
+
4. Queries the actor's Turbopuffer namespace twice:
38
38
+
- vector ANN over `vector`
39
39
+
- BM25 over `text`
40
40
+
5. Fuses ranked rows, dedupes equivalent artifacts, and returns display-ready
41
41
+
results.
42
42
+
43
43
+
If both Turbopuffer queries return 404, the response sets `needsIndex: true`.
44
44
+
The frontend then calls `/api/update`.
45
45
+
46
46
+
### `/api/update`
47
47
+
48
48
+
Streams newline-delimited JSON progress events.
49
49
+
50
50
+
1. Resolves the actor.
51
51
+
2. Deletes the actor's Turbopuffer namespace when `replace: true`.
52
52
+
3. Fetches the actor repo through `com.atproto.sync.getRepo` from the actor's
53
53
+
PDS.
54
54
+
4. Walks the CAR with `@atcute/repo`.
55
55
+
5. Infers an artifact envelope from each record.
56
56
+
6. Embeds up to `INDEX_LIMIT` text-bearing artifacts with Voyage.
57
57
+
7. Upserts rows into Turbopuffer.
58
58
+
8. Runs the query against the rebuilt namespace and emits a final `done` event.
59
59
+
60
60
+
## Artifact Envelope
61
61
+
62
62
+
Pensieve indexes arbitrary records by trying to infer a generic artifact:
63
63
+
64
64
+
```js
65
65
+
{
66
66
+
kind,
67
67
+
title,
68
68
+
body,
69
69
+
url,
70
70
+
media,
71
71
+
refs,
72
72
+
date,
73
73
+
confidence
74
74
+
}
75
75
+
```
76
76
+
77
77
+
This is deliberately not a curated collection allowlist. The extraction pass
78
78
+
walks the record, drops obvious protocol noise, scores likely title/body/url/date
79
79
+
fields, and stores both the searchable text and the display attributes in
80
80
+
Turbopuffer.
81
81
+
82
82
+
The UI then renders a small digest from the envelope:
83
83
+
84
84
+
- platform/app badge and name
85
85
+
- artifact kind
86
86
+
- date and duplicate count
87
87
+
- title/body
88
88
+
- primary native URL when known
89
89
+
- `details` link to PDSLS for raw inspection
90
90
+
91
91
+
## Link Hierarchy
92
92
+
93
93
+
Primary clicks should take the user to the most useful human destination:
94
94
+
95
95
+
1. `artifact.url` when the record contains one.
96
96
+
2. Known native URL builders, currently Bluesky posts and some Tangled records.
97
97
+
3. PDSLS as fallback.
98
98
+
99
99
+
PDSLS is still useful for inspecting raw records, but it should not be the main
100
100
+
destination when Pensieve already knows a better app URL.
101
101
+
102
102
+
## Caching / Reuse
103
103
+
104
104
+
There is no separate cache layer. Turbopuffer namespaces are the cache:
105
105
+
106
106
+
- first search for an actor can be slow because it builds the namespace
107
107
+
- later searches for that actor are fast because `/api/search` reuses it
108
108
+
- `/api/update` is a rebuild path and deletes the namespace first
109
109
+
110
110
+
Namespace naming:
111
111
+
112
112
+
```text
113
113
+
${TURBOPUFFER_NAMESPACE_PREFIX}-${did with punctuation replaced}
114
114
+
```
115
115
+
116
116
+
## Zig Prototype
117
117
+
118
118
+
The Zig code is an earlier extractor and still useful reference:
119
119
+
120
120
+
- `src/repo_walk.zig` streams `com.atproto.sync.getRepo` to a temp file, mmaps
121
121
+
the CAR, indexes blocks, and walks the MST.
122
122
+
- `src/record_text.zig` flattens records into embedding-ready text.
123
123
+
- `src/main.zig` exposes `extract` and `demo`.
124
124
+
125
125
+
The deployed Worker does not call the Zig binary today.
···
1
1
+
# Operations
2
2
+
3
3
+
## Deployment
4
4
+
5
5
+
Pensieve deploys as a Cloudflare Worker.
6
6
+
7
7
+
```sh
8
8
+
npm install
9
9
+
npm run dry-run
10
10
+
npm run deploy
11
11
+
```
12
12
+
13
13
+
The Worker config is in `wrangler.jsonc`.
14
14
+
15
15
+
Live URL:
16
16
+
17
17
+
```text
18
18
+
https://pensieve.n8-3e9.workers.dev
19
19
+
```
20
20
+
21
21
+
## Required Secrets
22
22
+
23
23
+
Cloudflare Worker secrets:
24
24
+
25
25
+
- `VOYAGE_API_KEY` - used for query and document embeddings.
26
26
+
- `TURBOPUFFER_API_KEY` - used for namespace delete/upsert/query.
27
27
+
28
28
+
Set them with:
29
29
+
30
30
+
```sh
31
31
+
npx wrangler secret put VOYAGE_API_KEY
32
32
+
npx wrangler secret put TURBOPUFFER_API_KEY
33
33
+
```
34
34
+
35
35
+
Do not commit local secret files. `.env` and `.dev.vars` are ignored.
36
36
+
37
37
+
For local development, either:
38
38
+
39
39
+
```sh
40
40
+
npx wrangler dev
41
41
+
```
42
42
+
43
43
+
with `.dev.vars`, or run through 1Password:
44
44
+
45
45
+
```sh
46
46
+
op run --env-file .env -- npm run dev
47
47
+
```
48
48
+
49
49
+
Prefer 1Password or Wrangler secrets over printing values in the terminal.
50
50
+
51
51
+
## Non-Secret Configuration
52
52
+
53
53
+
`wrangler.jsonc` defines:
54
54
+
55
55
+
```jsonc
56
56
+
"TURBOPUFFER_NAMESPACE_PREFIX": "pensieve"
57
57
+
```
58
58
+
59
59
+
Changing this creates a new logical cache namespace. Existing indexed actors in
60
60
+
the old prefix will not be reused.
61
61
+
62
62
+
## Rebuilding An Actor
63
63
+
64
64
+
The app rebuilds an actor automatically when `/api/search` reports a missing
65
65
+
namespace. To force a rebuild manually:
66
66
+
67
67
+
```sh
68
68
+
curl -N "https://pensieve.n8-3e9.workers.dev/api/update?actor=zzstoatzz.io&q=bufo&top_k=8"
69
69
+
```
70
70
+
71
71
+
`/api/update` streams NDJSON progress and deletes the actor namespace before
72
72
+
re-indexing.
73
73
+
74
74
+
## Smoke Checks
75
75
+
76
76
+
Syntax and bundle:
77
77
+
78
78
+
```sh
79
79
+
node --check src/worker.js
80
80
+
npm run dry-run
81
81
+
```
82
82
+
83
83
+
Live API:
84
84
+
85
85
+
```sh
86
86
+
curl "https://pensieve.n8-3e9.workers.dev/health"
87
87
+
curl "https://pensieve.n8-3e9.workers.dev/api/search?actor=zzstoatzz.io&q=bufo"
88
88
+
```
89
89
+
90
90
+
Expected search behavior:
91
91
+
92
92
+
- if the actor has already been indexed, results return quickly
93
93
+
- if not, `needsIndex: true` tells the frontend to call `/api/update`
94
94
+
95
95
+
## Known Limits
96
96
+
97
97
+
- `INDEX_LIMIT` currently caps indexing at 5,000 searchable records per actor.
98
98
+
- No background freshness job exists yet. A namespace can get stale until
99
99
+
`/api/update` is called.
100
100
+
- Artifact extraction is heuristic. It intentionally favors a generic semantic
101
101
+
envelope over per-app hardcoded collection logic.
102
102
+
- The Worker currently owns both backend and frontend in one file. That is fine
103
103
+
for the MVP but should be split if the UI keeps growing.
···
1
1
+
{
2
2
+
"name": "pensieve",
3
3
+
"version": "0.1.0",
4
4
+
"lockfileVersion": 3,
5
5
+
"requires": true,
6
6
+
"packages": {
7
7
+
"": {
8
8
+
"name": "pensieve",
9
9
+
"version": "0.1.0",
10
10
+
"dependencies": {
11
11
+
"@atcute/atproto": "^4.0.3",
12
12
+
"@atcute/car": "^6.0.2",
13
13
+
"@atcute/cbor": "^2.3.5",
14
14
+
"@atcute/cid": "^2.4.2",
15
15
+
"@atcute/client": "^5.1.0",
16
16
+
"@atcute/identity-resolver": "^2.0.1",
17
17
+
"@atcute/mst": "^1.0.2",
18
18
+
"@atcute/repo": "^1.0.2"
19
19
+
},
20
20
+
"devDependencies": {
21
21
+
"wrangler": "^4.99.0"
22
22
+
}
23
23
+
},
24
24
+
"node_modules/@atcute/atproto": {
25
25
+
"version": "4.0.3",
26
26
+
"resolved": "https://registry.npmjs.org/@atcute/atproto/-/atproto-4.0.3.tgz",
27
27
+
"integrity": "sha512-BNylfO7nK0yYBpSpnGhOYgrJTeZWrXHPrb6tOQmp9A3Am0epctIWm6/5lPC4ZNPHpUbwr5w/LzH/v7kjAoKEDg==",
28
28
+
"license": "0BSD",
29
29
+
"dependencies": {
30
30
+
"@atcute/lexicons": "^2.0.2"
31
31
+
},
32
32
+
"peerDependencies": {
33
33
+
"@atcute/lexicons": "^2.0.0"
34
34
+
}
35
35
+
},
36
36
+
"node_modules/@atcute/car": {
37
37
+
"version": "6.0.2",
38
38
+
"resolved": "https://registry.npmjs.org/@atcute/car/-/car-6.0.2.tgz",
39
39
+
"integrity": "sha512-6AaLjO0zrFD8R/aK7jwrqHEmLzfVilu/5pv4LAcUjxIYfBrw+nThV2FVLPtA4Jt59tIhjp5tQDP+9sx1eqQp+A==",
40
40
+
"license": "0BSD",
41
41
+
"dependencies": {
42
42
+
"@atcute/cbor": "^2.3.5",
43
43
+
"@atcute/cid": "^2.4.2",
44
44
+
"@atcute/uint8array": "^1.1.3",
45
45
+
"@atcute/varint": "^2.0.1"
46
46
+
},
47
47
+
"peerDependencies": {
48
48
+
"@atcute/cbor": "^2.0.0",
49
49
+
"@atcute/cid": "^2.0.0"
50
50
+
}
51
51
+
},
52
52
+
"node_modules/@atcute/cbor": {
53
53
+
"version": "2.3.5",
54
54
+
"resolved": "https://registry.npmjs.org/@atcute/cbor/-/cbor-2.3.5.tgz",
55
55
+
"integrity": "sha512-NvgeibMqtfeD6/4NMTnBwhjyB/F945UdnzeZMEsGb2eXSalOBzutgNmpnBPTj5FiN6Sl9VYhE3qDQfyox+P8aw==",
56
56
+
"license": "0BSD",
57
57
+
"dependencies": {
58
58
+
"@atcute/cid": "^2.4.2",
59
59
+
"@atcute/multibase": "^1.2.4",
60
60
+
"@atcute/uint8array": "^1.1.3"
61
61
+
},
62
62
+
"peerDependencies": {
63
63
+
"@atcute/cid": "^2.0.0"
64
64
+
}
65
65
+
},
66
66
+
"node_modules/@atcute/cid": {
67
67
+
"version": "2.4.2",
68
68
+
"resolved": "https://registry.npmjs.org/@atcute/cid/-/cid-2.4.2.tgz",
69
69
+
"integrity": "sha512-Uy48yfyo/hPQXF+XMXWIGomF6v8IvX5ErjozXMV7rlfU3EV7PSVX3J7plJXV6MRC3iI1z3PgTZTS7V0drCQVVw==",
70
70
+
"license": "0BSD",
71
71
+
"dependencies": {
72
72
+
"@atcute/multibase": "^1.2.4",
73
73
+
"@atcute/uint8array": "^1.1.3"
74
74
+
}
75
75
+
},
76
76
+
"node_modules/@atcute/client": {
77
77
+
"version": "5.1.0",
78
78
+
"resolved": "https://registry.npmjs.org/@atcute/client/-/client-5.1.0.tgz",
79
79
+
"integrity": "sha512-l2LYCY43QvrOsvS+q1d959x0yVeXQ5F7haloCB8MLzrTKT3s9fc4S3Kr+8JkgjPtdapgOPIeEdhWcrzP5WNLRg==",
80
80
+
"license": "0BSD",
81
81
+
"dependencies": {
82
82
+
"@atcute/identity": "^2.0.0",
83
83
+
"@atcute/lexicons": "^2.0.0"
84
84
+
},
85
85
+
"peerDependencies": {
86
86
+
"@atcute/lexicons": "^2.0.0"
87
87
+
}
88
88
+
},
89
89
+
"node_modules/@atcute/crypto": {
90
90
+
"version": "2.4.2",
91
91
+
"resolved": "https://registry.npmjs.org/@atcute/crypto/-/crypto-2.4.2.tgz",
92
92
+
"integrity": "sha512-WKVVstCsgGZaaEJFemC6UPh5MtEB2SMU0tiOi744i9T2a6oInJg3VnbTzQsGkyQqqTNC30IHF7//Dwi4hYnu1g==",
93
93
+
"license": "0BSD",
94
94
+
"dependencies": {
95
95
+
"@atcute/multibase": "^1.2.4",
96
96
+
"@atcute/uint8array": "^1.1.3",
97
97
+
"@noble/secp256k1": "^3.1.0"
98
98
+
}
99
99
+
},
100
100
+
"node_modules/@atcute/identity": {
101
101
+
"version": "2.0.1",
102
102
+
"resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.1.tgz",
103
103
+
"integrity": "sha512-FEURUvl30SyyWWikkvm+MLz0Snuf0OF10L/qxRhWjj6qDB5Ib+XWhiBuwidjvhCkrCepTUNLbj4TlUm/gHaUig==",
104
104
+
"license": "0BSD",
105
105
+
"dependencies": {
106
106
+
"@atcute/lexicons": "^2.0.2",
107
107
+
"valibot": "^1.4.1"
108
108
+
},
109
109
+
"peerDependencies": {
110
110
+
"@atcute/lexicons": "^2.0.0"
111
111
+
}
112
112
+
},
113
113
+
"node_modules/@atcute/identity-resolver": {
114
114
+
"version": "2.0.1",
115
115
+
"resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-2.0.1.tgz",
116
116
+
"integrity": "sha512-0enA9w7XnbbqsZ5Rcl6jXLf7ZZuwFQ9dBmxFq3qOxPHLaCETsqsrQflXDPqiM27TnZwYq8sqCV5D1mFOksggDQ==",
117
117
+
"license": "0BSD",
118
118
+
"dependencies": {
119
119
+
"@atcute/lexicons": "^2.0.2",
120
120
+
"@atcute/util-fetch": "^2.0.1",
121
121
+
"valibot": "^1.4.1"
122
122
+
},
123
123
+
"peerDependencies": {
124
124
+
"@atcute/identity": "^2.0.0",
125
125
+
"@atcute/lexicons": "^2.0.0"
126
126
+
}
127
127
+
},
128
128
+
"node_modules/@atcute/lexicons": {
129
129
+
"version": "2.0.2",
130
130
+
"resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.0.2.tgz",
131
131
+
"integrity": "sha512-ATBADJAy4KQ76NB86BjgYKrRdbDRUo76Cbqna4WIfQAgN105Rcy972MiNKs+BSmcOOM3WakilgTm0CXD4RC0iA==",
132
132
+
"license": "0BSD",
133
133
+
"dependencies": {
134
134
+
"@atcute/uint8array": "^1.1.3",
135
135
+
"@atcute/util-text": "^1.3.3",
136
136
+
"@standard-schema/spec": "^1.1.0",
137
137
+
"esm-env": "^1.2.2"
138
138
+
}
139
139
+
},
140
140
+
"node_modules/@atcute/mst": {
141
141
+
"version": "1.0.2",
142
142
+
"resolved": "https://registry.npmjs.org/@atcute/mst/-/mst-1.0.2.tgz",
143
143
+
"integrity": "sha512-fb98Ygs0PjVAOE4WmtMGqi+PS4Y0Jsip4CIkzWBxyIcbfix8kBGQUwZi54k4rKiGfT9qroCY2rlaPFzwkkAFzQ==",
144
144
+
"license": "0BSD",
145
145
+
"dependencies": {
146
146
+
"@atcute/cbor": "^2.3.5",
147
147
+
"@atcute/cid": "^2.4.2",
148
148
+
"@atcute/uint8array": "^1.1.3"
149
149
+
},
150
150
+
"peerDependencies": {
151
151
+
"@atcute/cbor": "^2.0.0",
152
152
+
"@atcute/cid": "^2.0.0"
153
153
+
}
154
154
+
},
155
155
+
"node_modules/@atcute/multibase": {
156
156
+
"version": "1.2.4",
157
157
+
"resolved": "https://registry.npmjs.org/@atcute/multibase/-/multibase-1.2.4.tgz",
158
158
+
"integrity": "sha512-WeX12hvFZEim6C+cyv7Eqd93w6DzubNWQGmTFBghjsEuXvMe4HbBCYvsti0OUnbA5qLBPlsTyssQUJeLlHCzIw==",
159
159
+
"license": "0BSD",
160
160
+
"dependencies": {
161
161
+
"@atcute/uint8array": "^1.1.3"
162
162
+
}
163
163
+
},
164
164
+
"node_modules/@atcute/repo": {
165
165
+
"version": "1.0.2",
166
166
+
"resolved": "https://registry.npmjs.org/@atcute/repo/-/repo-1.0.2.tgz",
167
167
+
"integrity": "sha512-fsAuGbagOW52nSFtf/TWBx25A6xpZvzxBk0+S14Bp5sGNq7JoQ0zyu40P7b6+L8b/buB+1ACHPntYsKqCI4Awg==",
168
168
+
"license": "0BSD",
169
169
+
"dependencies": {
170
170
+
"@atcute/car": "^6.0.2",
171
171
+
"@atcute/cbor": "^2.3.5",
172
172
+
"@atcute/cid": "^2.4.2",
173
173
+
"@atcute/crypto": "^2.4.2",
174
174
+
"@atcute/lexicons": "^2.0.2",
175
175
+
"@atcute/mst": "^1.0.2",
176
176
+
"@atcute/uint8array": "^1.1.3"
177
177
+
},
178
178
+
"peerDependencies": {
179
179
+
"@atcute/cbor": "^2.0.0",
180
180
+
"@atcute/cid": "^2.0.0",
181
181
+
"@atcute/lexicons": "^2.0.0"
182
182
+
}
183
183
+
},
184
184
+
"node_modules/@atcute/uint8array": {
185
185
+
"version": "1.1.3",
186
186
+
"resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.3.tgz",
187
187
+
"integrity": "sha512-2KLcMQHUFtntY3tEjdyqqq1tR9hvPFndluWFCa637QY0cMyvq0fHSnhmZeWaSRIXMCwVDY3TLLWHNOHEWFb11g==",
188
188
+
"license": "0BSD"
189
189
+
},
190
190
+
"node_modules/@atcute/util-fetch": {
191
191
+
"version": "2.0.1",
192
192
+
"resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-2.0.1.tgz",
193
193
+
"integrity": "sha512-ugWTOLemA8OxSOj7c8q6ncRmBGFDHSwwE1YinO+PCtaw6WLQFGBfHn+yikQ0e3wTK2t4IPjQ5PxZcRXm961ZVA==",
194
194
+
"license": "0BSD",
195
195
+
"dependencies": {
196
196
+
"valibot": "^1.4.1"
197
197
+
}
198
198
+
},
199
199
+
"node_modules/@atcute/util-text": {
200
200
+
"version": "1.3.3",
201
201
+
"resolved": "https://registry.npmjs.org/@atcute/util-text/-/util-text-1.3.3.tgz",
202
202
+
"integrity": "sha512-WhedTmg/msFhrdwXw9RjnNcDl8Vmisxl4+Vzyf5k3+8Gj5TKQg72dLSDtBNmNLd61RbHjgfQRBgE0ez6q/jciw==",
203
203
+
"license": "0BSD",
204
204
+
"dependencies": {
205
205
+
"unicode-segmenter": "^0.14.5"
206
206
+
}
207
207
+
},
208
208
+
"node_modules/@atcute/varint": {
209
209
+
"version": "2.0.1",
210
210
+
"resolved": "https://registry.npmjs.org/@atcute/varint/-/varint-2.0.1.tgz",
211
211
+
"integrity": "sha512-reTtgQ1VPGVRcaTohKVb1tUbpvE7MpguoNS0gjhsxDWFY3t1yqWWvwKK6cJodAmePML36JombvgFwfsblir9Jg==",
212
212
+
"license": "0BSD"
213
213
+
},
214
214
+
"node_modules/@cloudflare/kv-asset-handler": {
215
215
+
"version": "0.5.0",
216
216
+
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz",
217
217
+
"integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==",
218
218
+
"dev": true,
219
219
+
"license": "MIT OR Apache-2.0",
220
220
+
"engines": {
221
221
+
"node": ">=22.0.0"
222
222
+
}
223
223
+
},
224
224
+
"node_modules/@cloudflare/unenv-preset": {
225
225
+
"version": "2.16.1",
226
226
+
"resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz",
227
227
+
"integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==",
228
228
+
"dev": true,
229
229
+
"license": "MIT OR Apache-2.0",
230
230
+
"peerDependencies": {
231
231
+
"unenv": "2.0.0-rc.24",
232
232
+
"workerd": ">1.20260305.0 <2.0.0-0"
233
233
+
},
234
234
+
"peerDependenciesMeta": {
235
235
+
"workerd": {
236
236
+
"optional": true
237
237
+
}
238
238
+
}
239
239
+
},
240
240
+
"node_modules/@cloudflare/workerd-darwin-64": {
241
241
+
"version": "1.20260625.1",
242
242
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260625.1.tgz",
243
243
+
"integrity": "sha512-naCfBv0WnnTQIQPTniqMoUlklOIFjrAcSn1X+IAOhY8aFLF/xGYtFjs1eEE8sFib3ZuChGGpU23FFORVczqr0A==",
244
244
+
"cpu": [
245
245
+
"x64"
246
246
+
],
247
247
+
"dev": true,
248
248
+
"license": "Apache-2.0",
249
249
+
"optional": true,
250
250
+
"os": [
251
251
+
"darwin"
252
252
+
],
253
253
+
"engines": {
254
254
+
"node": ">=16"
255
255
+
}
256
256
+
},
257
257
+
"node_modules/@cloudflare/workerd-darwin-arm64": {
258
258
+
"version": "1.20260625.1",
259
259
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260625.1.tgz",
260
260
+
"integrity": "sha512-jmH6zjp6Wrux46+qtFwDwrj+vd7s5bdwEqeGvdnwE0a4IEeAhKs0L42HQOyID+g5lkrHq9m55+AbhtmRAm63Pw==",
261
261
+
"cpu": [
262
262
+
"arm64"
263
263
+
],
264
264
+
"dev": true,
265
265
+
"license": "Apache-2.0",
266
266
+
"optional": true,
267
267
+
"os": [
268
268
+
"darwin"
269
269
+
],
270
270
+
"engines": {
271
271
+
"node": ">=16"
272
272
+
}
273
273
+
},
274
274
+
"node_modules/@cloudflare/workerd-linux-64": {
275
275
+
"version": "1.20260625.1",
276
276
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260625.1.tgz",
277
277
+
"integrity": "sha512-MiQkpA/dX8d83Zp64pzHUKfd6ca4cvwxnNobSP6CnXvfESvnNI9pfa+nfwnParla36sPmnYntNkjR7NjRuDeKQ==",
278
278
+
"cpu": [
279
279
+
"x64"
280
280
+
],
281
281
+
"dev": true,
282
282
+
"license": "Apache-2.0",
283
283
+
"optional": true,
284
284
+
"os": [
285
285
+
"linux"
286
286
+
],
287
287
+
"engines": {
288
288
+
"node": ">=16"
289
289
+
}
290
290
+
},
291
291
+
"node_modules/@cloudflare/workerd-linux-arm64": {
292
292
+
"version": "1.20260625.1",
293
293
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260625.1.tgz",
294
294
+
"integrity": "sha512-LxxW7Qv60Xvv37+w6gUSDpYZziyqMy+cZWd9IvSA5ehVgKAxmzEaYPMiSZlxk32nbIWL9u/tfjXYCOKJ4Lo+XQ==",
295
295
+
"cpu": [
296
296
+
"arm64"
297
297
+
],
298
298
+
"dev": true,
299
299
+
"license": "Apache-2.0",
300
300
+
"optional": true,
301
301
+
"os": [
302
302
+
"linux"
303
303
+
],
304
304
+
"engines": {
305
305
+
"node": ">=16"
306
306
+
}
307
307
+
},
308
308
+
"node_modules/@cloudflare/workerd-windows-64": {
309
309
+
"version": "1.20260625.1",
310
310
+
"resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260625.1.tgz",
311
311
+
"integrity": "sha512-LH6iIX1HHaTwVKV5VokDxxUErXJzQoNZFRwVm7Vx/3fB/ApcTcRCUaMqcxI4as94jEUqg+pmX5czOndiveohow==",
312
312
+
"cpu": [
313
313
+
"x64"
314
314
+
],
315
315
+
"dev": true,
316
316
+
"license": "Apache-2.0",
317
317
+
"optional": true,
318
318
+
"os": [
319
319
+
"win32"
320
320
+
],
321
321
+
"engines": {
322
322
+
"node": ">=16"
323
323
+
}
324
324
+
},
325
325
+
"node_modules/@cspotcode/source-map-support": {
326
326
+
"version": "0.8.1",
327
327
+
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
328
328
+
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
329
329
+
"dev": true,
330
330
+
"license": "MIT",
331
331
+
"dependencies": {
332
332
+
"@jridgewell/trace-mapping": "0.3.9"
333
333
+
},
334
334
+
"engines": {
335
335
+
"node": ">=12"
336
336
+
}
337
337
+
},
338
338
+
"node_modules/@emnapi/runtime": {
339
339
+
"version": "1.11.1",
340
340
+
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
341
341
+
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
342
342
+
"dev": true,
343
343
+
"license": "MIT",
344
344
+
"optional": true,
345
345
+
"dependencies": {
346
346
+
"tslib": "^2.4.0"
347
347
+
}
348
348
+
},
349
349
+
"node_modules/@esbuild/aix-ppc64": {
350
350
+
"version": "0.28.1",
351
351
+
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
352
352
+
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
353
353
+
"cpu": [
354
354
+
"ppc64"
355
355
+
],
356
356
+
"dev": true,
357
357
+
"license": "MIT",
358
358
+
"optional": true,
359
359
+
"os": [
360
360
+
"aix"
361
361
+
],
362
362
+
"engines": {
363
363
+
"node": ">=18"
364
364
+
}
365
365
+
},
366
366
+
"node_modules/@esbuild/android-arm": {
367
367
+
"version": "0.28.1",
368
368
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
369
369
+
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
370
370
+
"cpu": [
371
371
+
"arm"
372
372
+
],
373
373
+
"dev": true,
374
374
+
"license": "MIT",
375
375
+
"optional": true,
376
376
+
"os": [
377
377
+
"android"
378
378
+
],
379
379
+
"engines": {
380
380
+
"node": ">=18"
381
381
+
}
382
382
+
},
383
383
+
"node_modules/@esbuild/android-arm64": {
384
384
+
"version": "0.28.1",
385
385
+
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
386
386
+
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
387
387
+
"cpu": [
388
388
+
"arm64"
389
389
+
],
390
390
+
"dev": true,
391
391
+
"license": "MIT",
392
392
+
"optional": true,
393
393
+
"os": [
394
394
+
"android"
395
395
+
],
396
396
+
"engines": {
397
397
+
"node": ">=18"
398
398
+
}
399
399
+
},
400
400
+
"node_modules/@esbuild/android-x64": {
401
401
+
"version": "0.28.1",
402
402
+
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
403
403
+
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
404
404
+
"cpu": [
405
405
+
"x64"
406
406
+
],
407
407
+
"dev": true,
408
408
+
"license": "MIT",
409
409
+
"optional": true,
410
410
+
"os": [
411
411
+
"android"
412
412
+
],
413
413
+
"engines": {
414
414
+
"node": ">=18"
415
415
+
}
416
416
+
},
417
417
+
"node_modules/@esbuild/darwin-arm64": {
418
418
+
"version": "0.28.1",
419
419
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
420
420
+
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
421
421
+
"cpu": [
422
422
+
"arm64"
423
423
+
],
424
424
+
"dev": true,
425
425
+
"license": "MIT",
426
426
+
"optional": true,
427
427
+
"os": [
428
428
+
"darwin"
429
429
+
],
430
430
+
"engines": {
431
431
+
"node": ">=18"
432
432
+
}
433
433
+
},
434
434
+
"node_modules/@esbuild/darwin-x64": {
435
435
+
"version": "0.28.1",
436
436
+
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
437
437
+
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
438
438
+
"cpu": [
439
439
+
"x64"
440
440
+
],
441
441
+
"dev": true,
442
442
+
"license": "MIT",
443
443
+
"optional": true,
444
444
+
"os": [
445
445
+
"darwin"
446
446
+
],
447
447
+
"engines": {
448
448
+
"node": ">=18"
449
449
+
}
450
450
+
},
451
451
+
"node_modules/@esbuild/freebsd-arm64": {
452
452
+
"version": "0.28.1",
453
453
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
454
454
+
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
455
455
+
"cpu": [
456
456
+
"arm64"
457
457
+
],
458
458
+
"dev": true,
459
459
+
"license": "MIT",
460
460
+
"optional": true,
461
461
+
"os": [
462
462
+
"freebsd"
463
463
+
],
464
464
+
"engines": {
465
465
+
"node": ">=18"
466
466
+
}
467
467
+
},
468
468
+
"node_modules/@esbuild/freebsd-x64": {
469
469
+
"version": "0.28.1",
470
470
+
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
471
471
+
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
472
472
+
"cpu": [
473
473
+
"x64"
474
474
+
],
475
475
+
"dev": true,
476
476
+
"license": "MIT",
477
477
+
"optional": true,
478
478
+
"os": [
479
479
+
"freebsd"
480
480
+
],
481
481
+
"engines": {
482
482
+
"node": ">=18"
483
483
+
}
484
484
+
},
485
485
+
"node_modules/@esbuild/linux-arm": {
486
486
+
"version": "0.28.1",
487
487
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
488
488
+
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
489
489
+
"cpu": [
490
490
+
"arm"
491
491
+
],
492
492
+
"dev": true,
493
493
+
"license": "MIT",
494
494
+
"optional": true,
495
495
+
"os": [
496
496
+
"linux"
497
497
+
],
498
498
+
"engines": {
499
499
+
"node": ">=18"
500
500
+
}
501
501
+
},
502
502
+
"node_modules/@esbuild/linux-arm64": {
503
503
+
"version": "0.28.1",
504
504
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
505
505
+
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
506
506
+
"cpu": [
507
507
+
"arm64"
508
508
+
],
509
509
+
"dev": true,
510
510
+
"license": "MIT",
511
511
+
"optional": true,
512
512
+
"os": [
513
513
+
"linux"
514
514
+
],
515
515
+
"engines": {
516
516
+
"node": ">=18"
517
517
+
}
518
518
+
},
519
519
+
"node_modules/@esbuild/linux-ia32": {
520
520
+
"version": "0.28.1",
521
521
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
522
522
+
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
523
523
+
"cpu": [
524
524
+
"ia32"
525
525
+
],
526
526
+
"dev": true,
527
527
+
"license": "MIT",
528
528
+
"optional": true,
529
529
+
"os": [
530
530
+
"linux"
531
531
+
],
532
532
+
"engines": {
533
533
+
"node": ">=18"
534
534
+
}
535
535
+
},
536
536
+
"node_modules/@esbuild/linux-loong64": {
537
537
+
"version": "0.28.1",
538
538
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
539
539
+
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
540
540
+
"cpu": [
541
541
+
"loong64"
542
542
+
],
543
543
+
"dev": true,
544
544
+
"license": "MIT",
545
545
+
"optional": true,
546
546
+
"os": [
547
547
+
"linux"
548
548
+
],
549
549
+
"engines": {
550
550
+
"node": ">=18"
551
551
+
}
552
552
+
},
553
553
+
"node_modules/@esbuild/linux-mips64el": {
554
554
+
"version": "0.28.1",
555
555
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
556
556
+
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
557
557
+
"cpu": [
558
558
+
"mips64el"
559
559
+
],
560
560
+
"dev": true,
561
561
+
"license": "MIT",
562
562
+
"optional": true,
563
563
+
"os": [
564
564
+
"linux"
565
565
+
],
566
566
+
"engines": {
567
567
+
"node": ">=18"
568
568
+
}
569
569
+
},
570
570
+
"node_modules/@esbuild/linux-ppc64": {
571
571
+
"version": "0.28.1",
572
572
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
573
573
+
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
574
574
+
"cpu": [
575
575
+
"ppc64"
576
576
+
],
577
577
+
"dev": true,
578
578
+
"license": "MIT",
579
579
+
"optional": true,
580
580
+
"os": [
581
581
+
"linux"
582
582
+
],
583
583
+
"engines": {
584
584
+
"node": ">=18"
585
585
+
}
586
586
+
},
587
587
+
"node_modules/@esbuild/linux-riscv64": {
588
588
+
"version": "0.28.1",
589
589
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
590
590
+
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
591
591
+
"cpu": [
592
592
+
"riscv64"
593
593
+
],
594
594
+
"dev": true,
595
595
+
"license": "MIT",
596
596
+
"optional": true,
597
597
+
"os": [
598
598
+
"linux"
599
599
+
],
600
600
+
"engines": {
601
601
+
"node": ">=18"
602
602
+
}
603
603
+
},
604
604
+
"node_modules/@esbuild/linux-s390x": {
605
605
+
"version": "0.28.1",
606
606
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
607
607
+
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
608
608
+
"cpu": [
609
609
+
"s390x"
610
610
+
],
611
611
+
"dev": true,
612
612
+
"license": "MIT",
613
613
+
"optional": true,
614
614
+
"os": [
615
615
+
"linux"
616
616
+
],
617
617
+
"engines": {
618
618
+
"node": ">=18"
619
619
+
}
620
620
+
},
621
621
+
"node_modules/@esbuild/linux-x64": {
622
622
+
"version": "0.28.1",
623
623
+
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
624
624
+
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
625
625
+
"cpu": [
626
626
+
"x64"
627
627
+
],
628
628
+
"dev": true,
629
629
+
"license": "MIT",
630
630
+
"optional": true,
631
631
+
"os": [
632
632
+
"linux"
633
633
+
],
634
634
+
"engines": {
635
635
+
"node": ">=18"
636
636
+
}
637
637
+
},
638
638
+
"node_modules/@esbuild/netbsd-arm64": {
639
639
+
"version": "0.28.1",
640
640
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
641
641
+
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
642
642
+
"cpu": [
643
643
+
"arm64"
644
644
+
],
645
645
+
"dev": true,
646
646
+
"license": "MIT",
647
647
+
"optional": true,
648
648
+
"os": [
649
649
+
"netbsd"
650
650
+
],
651
651
+
"engines": {
652
652
+
"node": ">=18"
653
653
+
}
654
654
+
},
655
655
+
"node_modules/@esbuild/netbsd-x64": {
656
656
+
"version": "0.28.1",
657
657
+
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
658
658
+
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
659
659
+
"cpu": [
660
660
+
"x64"
661
661
+
],
662
662
+
"dev": true,
663
663
+
"license": "MIT",
664
664
+
"optional": true,
665
665
+
"os": [
666
666
+
"netbsd"
667
667
+
],
668
668
+
"engines": {
669
669
+
"node": ">=18"
670
670
+
}
671
671
+
},
672
672
+
"node_modules/@esbuild/openbsd-arm64": {
673
673
+
"version": "0.28.1",
674
674
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
675
675
+
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
676
676
+
"cpu": [
677
677
+
"arm64"
678
678
+
],
679
679
+
"dev": true,
680
680
+
"license": "MIT",
681
681
+
"optional": true,
682
682
+
"os": [
683
683
+
"openbsd"
684
684
+
],
685
685
+
"engines": {
686
686
+
"node": ">=18"
687
687
+
}
688
688
+
},
689
689
+
"node_modules/@esbuild/openbsd-x64": {
690
690
+
"version": "0.28.1",
691
691
+
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
692
692
+
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
693
693
+
"cpu": [
694
694
+
"x64"
695
695
+
],
696
696
+
"dev": true,
697
697
+
"license": "MIT",
698
698
+
"optional": true,
699
699
+
"os": [
700
700
+
"openbsd"
701
701
+
],
702
702
+
"engines": {
703
703
+
"node": ">=18"
704
704
+
}
705
705
+
},
706
706
+
"node_modules/@esbuild/openharmony-arm64": {
707
707
+
"version": "0.28.1",
708
708
+
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
709
709
+
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
710
710
+
"cpu": [
711
711
+
"arm64"
712
712
+
],
713
713
+
"dev": true,
714
714
+
"license": "MIT",
715
715
+
"optional": true,
716
716
+
"os": [
717
717
+
"openharmony"
718
718
+
],
719
719
+
"engines": {
720
720
+
"node": ">=18"
721
721
+
}
722
722
+
},
723
723
+
"node_modules/@esbuild/sunos-x64": {
724
724
+
"version": "0.28.1",
725
725
+
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
726
726
+
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
727
727
+
"cpu": [
728
728
+
"x64"
729
729
+
],
730
730
+
"dev": true,
731
731
+
"license": "MIT",
732
732
+
"optional": true,
733
733
+
"os": [
734
734
+
"sunos"
735
735
+
],
736
736
+
"engines": {
737
737
+
"node": ">=18"
738
738
+
}
739
739
+
},
740
740
+
"node_modules/@esbuild/win32-arm64": {
741
741
+
"version": "0.28.1",
742
742
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
743
743
+
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
744
744
+
"cpu": [
745
745
+
"arm64"
746
746
+
],
747
747
+
"dev": true,
748
748
+
"license": "MIT",
749
749
+
"optional": true,
750
750
+
"os": [
751
751
+
"win32"
752
752
+
],
753
753
+
"engines": {
754
754
+
"node": ">=18"
755
755
+
}
756
756
+
},
757
757
+
"node_modules/@esbuild/win32-ia32": {
758
758
+
"version": "0.28.1",
759
759
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
760
760
+
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
761
761
+
"cpu": [
762
762
+
"ia32"
763
763
+
],
764
764
+
"dev": true,
765
765
+
"license": "MIT",
766
766
+
"optional": true,
767
767
+
"os": [
768
768
+
"win32"
769
769
+
],
770
770
+
"engines": {
771
771
+
"node": ">=18"
772
772
+
}
773
773
+
},
774
774
+
"node_modules/@esbuild/win32-x64": {
775
775
+
"version": "0.28.1",
776
776
+
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
777
777
+
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
778
778
+
"cpu": [
779
779
+
"x64"
780
780
+
],
781
781
+
"dev": true,
782
782
+
"license": "MIT",
783
783
+
"optional": true,
784
784
+
"os": [
785
785
+
"win32"
786
786
+
],
787
787
+
"engines": {
788
788
+
"node": ">=18"
789
789
+
}
790
790
+
},
791
791
+
"node_modules/@img/colour": {
792
792
+
"version": "1.1.0",
793
793
+
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
794
794
+
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
795
795
+
"dev": true,
796
796
+
"license": "MIT",
797
797
+
"engines": {
798
798
+
"node": ">=18"
799
799
+
}
800
800
+
},
801
801
+
"node_modules/@img/sharp-darwin-arm64": {
802
802
+
"version": "0.34.5",
803
803
+
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
804
804
+
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
805
805
+
"cpu": [
806
806
+
"arm64"
807
807
+
],
808
808
+
"dev": true,
809
809
+
"license": "Apache-2.0",
810
810
+
"optional": true,
811
811
+
"os": [
812
812
+
"darwin"
813
813
+
],
814
814
+
"engines": {
815
815
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
816
816
+
},
817
817
+
"funding": {
818
818
+
"url": "https://opencollective.com/libvips"
819
819
+
},
820
820
+
"optionalDependencies": {
821
821
+
"@img/sharp-libvips-darwin-arm64": "1.2.4"
822
822
+
}
823
823
+
},
824
824
+
"node_modules/@img/sharp-darwin-x64": {
825
825
+
"version": "0.34.5",
826
826
+
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
827
827
+
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
828
828
+
"cpu": [
829
829
+
"x64"
830
830
+
],
831
831
+
"dev": true,
832
832
+
"license": "Apache-2.0",
833
833
+
"optional": true,
834
834
+
"os": [
835
835
+
"darwin"
836
836
+
],
837
837
+
"engines": {
838
838
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
839
839
+
},
840
840
+
"funding": {
841
841
+
"url": "https://opencollective.com/libvips"
842
842
+
},
843
843
+
"optionalDependencies": {
844
844
+
"@img/sharp-libvips-darwin-x64": "1.2.4"
845
845
+
}
846
846
+
},
847
847
+
"node_modules/@img/sharp-libvips-darwin-arm64": {
848
848
+
"version": "1.2.4",
849
849
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
850
850
+
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
851
851
+
"cpu": [
852
852
+
"arm64"
853
853
+
],
854
854
+
"dev": true,
855
855
+
"license": "LGPL-3.0-or-later",
856
856
+
"optional": true,
857
857
+
"os": [
858
858
+
"darwin"
859
859
+
],
860
860
+
"funding": {
861
861
+
"url": "https://opencollective.com/libvips"
862
862
+
}
863
863
+
},
864
864
+
"node_modules/@img/sharp-libvips-darwin-x64": {
865
865
+
"version": "1.2.4",
866
866
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
867
867
+
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
868
868
+
"cpu": [
869
869
+
"x64"
870
870
+
],
871
871
+
"dev": true,
872
872
+
"license": "LGPL-3.0-or-later",
873
873
+
"optional": true,
874
874
+
"os": [
875
875
+
"darwin"
876
876
+
],
877
877
+
"funding": {
878
878
+
"url": "https://opencollective.com/libvips"
879
879
+
}
880
880
+
},
881
881
+
"node_modules/@img/sharp-libvips-linux-arm": {
882
882
+
"version": "1.2.4",
883
883
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
884
884
+
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
885
885
+
"cpu": [
886
886
+
"arm"
887
887
+
],
888
888
+
"dev": true,
889
889
+
"license": "LGPL-3.0-or-later",
890
890
+
"optional": true,
891
891
+
"os": [
892
892
+
"linux"
893
893
+
],
894
894
+
"funding": {
895
895
+
"url": "https://opencollective.com/libvips"
896
896
+
}
897
897
+
},
898
898
+
"node_modules/@img/sharp-libvips-linux-arm64": {
899
899
+
"version": "1.2.4",
900
900
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
901
901
+
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
902
902
+
"cpu": [
903
903
+
"arm64"
904
904
+
],
905
905
+
"dev": true,
906
906
+
"license": "LGPL-3.0-or-later",
907
907
+
"optional": true,
908
908
+
"os": [
909
909
+
"linux"
910
910
+
],
911
911
+
"funding": {
912
912
+
"url": "https://opencollective.com/libvips"
913
913
+
}
914
914
+
},
915
915
+
"node_modules/@img/sharp-libvips-linux-ppc64": {
916
916
+
"version": "1.2.4",
917
917
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
918
918
+
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
919
919
+
"cpu": [
920
920
+
"ppc64"
921
921
+
],
922
922
+
"dev": true,
923
923
+
"license": "LGPL-3.0-or-later",
924
924
+
"optional": true,
925
925
+
"os": [
926
926
+
"linux"
927
927
+
],
928
928
+
"funding": {
929
929
+
"url": "https://opencollective.com/libvips"
930
930
+
}
931
931
+
},
932
932
+
"node_modules/@img/sharp-libvips-linux-riscv64": {
933
933
+
"version": "1.2.4",
934
934
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
935
935
+
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
936
936
+
"cpu": [
937
937
+
"riscv64"
938
938
+
],
939
939
+
"dev": true,
940
940
+
"license": "LGPL-3.0-or-later",
941
941
+
"optional": true,
942
942
+
"os": [
943
943
+
"linux"
944
944
+
],
945
945
+
"funding": {
946
946
+
"url": "https://opencollective.com/libvips"
947
947
+
}
948
948
+
},
949
949
+
"node_modules/@img/sharp-libvips-linux-s390x": {
950
950
+
"version": "1.2.4",
951
951
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
952
952
+
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
953
953
+
"cpu": [
954
954
+
"s390x"
955
955
+
],
956
956
+
"dev": true,
957
957
+
"license": "LGPL-3.0-or-later",
958
958
+
"optional": true,
959
959
+
"os": [
960
960
+
"linux"
961
961
+
],
962
962
+
"funding": {
963
963
+
"url": "https://opencollective.com/libvips"
964
964
+
}
965
965
+
},
966
966
+
"node_modules/@img/sharp-libvips-linux-x64": {
967
967
+
"version": "1.2.4",
968
968
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
969
969
+
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
970
970
+
"cpu": [
971
971
+
"x64"
972
972
+
],
973
973
+
"dev": true,
974
974
+
"license": "LGPL-3.0-or-later",
975
975
+
"optional": true,
976
976
+
"os": [
977
977
+
"linux"
978
978
+
],
979
979
+
"funding": {
980
980
+
"url": "https://opencollective.com/libvips"
981
981
+
}
982
982
+
},
983
983
+
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
984
984
+
"version": "1.2.4",
985
985
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
986
986
+
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
987
987
+
"cpu": [
988
988
+
"arm64"
989
989
+
],
990
990
+
"dev": true,
991
991
+
"license": "LGPL-3.0-or-later",
992
992
+
"optional": true,
993
993
+
"os": [
994
994
+
"linux"
995
995
+
],
996
996
+
"funding": {
997
997
+
"url": "https://opencollective.com/libvips"
998
998
+
}
999
999
+
},
1000
1000
+
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
1001
1001
+
"version": "1.2.4",
1002
1002
+
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
1003
1003
+
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
1004
1004
+
"cpu": [
1005
1005
+
"x64"
1006
1006
+
],
1007
1007
+
"dev": true,
1008
1008
+
"license": "LGPL-3.0-or-later",
1009
1009
+
"optional": true,
1010
1010
+
"os": [
1011
1011
+
"linux"
1012
1012
+
],
1013
1013
+
"funding": {
1014
1014
+
"url": "https://opencollective.com/libvips"
1015
1015
+
}
1016
1016
+
},
1017
1017
+
"node_modules/@img/sharp-linux-arm": {
1018
1018
+
"version": "0.34.5",
1019
1019
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
1020
1020
+
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
1021
1021
+
"cpu": [
1022
1022
+
"arm"
1023
1023
+
],
1024
1024
+
"dev": true,
1025
1025
+
"license": "Apache-2.0",
1026
1026
+
"optional": true,
1027
1027
+
"os": [
1028
1028
+
"linux"
1029
1029
+
],
1030
1030
+
"engines": {
1031
1031
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1032
1032
+
},
1033
1033
+
"funding": {
1034
1034
+
"url": "https://opencollective.com/libvips"
1035
1035
+
},
1036
1036
+
"optionalDependencies": {
1037
1037
+
"@img/sharp-libvips-linux-arm": "1.2.4"
1038
1038
+
}
1039
1039
+
},
1040
1040
+
"node_modules/@img/sharp-linux-arm64": {
1041
1041
+
"version": "0.34.5",
1042
1042
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
1043
1043
+
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
1044
1044
+
"cpu": [
1045
1045
+
"arm64"
1046
1046
+
],
1047
1047
+
"dev": true,
1048
1048
+
"license": "Apache-2.0",
1049
1049
+
"optional": true,
1050
1050
+
"os": [
1051
1051
+
"linux"
1052
1052
+
],
1053
1053
+
"engines": {
1054
1054
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1055
1055
+
},
1056
1056
+
"funding": {
1057
1057
+
"url": "https://opencollective.com/libvips"
1058
1058
+
},
1059
1059
+
"optionalDependencies": {
1060
1060
+
"@img/sharp-libvips-linux-arm64": "1.2.4"
1061
1061
+
}
1062
1062
+
},
1063
1063
+
"node_modules/@img/sharp-linux-ppc64": {
1064
1064
+
"version": "0.34.5",
1065
1065
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
1066
1066
+
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
1067
1067
+
"cpu": [
1068
1068
+
"ppc64"
1069
1069
+
],
1070
1070
+
"dev": true,
1071
1071
+
"license": "Apache-2.0",
1072
1072
+
"optional": true,
1073
1073
+
"os": [
1074
1074
+
"linux"
1075
1075
+
],
1076
1076
+
"engines": {
1077
1077
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1078
1078
+
},
1079
1079
+
"funding": {
1080
1080
+
"url": "https://opencollective.com/libvips"
1081
1081
+
},
1082
1082
+
"optionalDependencies": {
1083
1083
+
"@img/sharp-libvips-linux-ppc64": "1.2.4"
1084
1084
+
}
1085
1085
+
},
1086
1086
+
"node_modules/@img/sharp-linux-riscv64": {
1087
1087
+
"version": "0.34.5",
1088
1088
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
1089
1089
+
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
1090
1090
+
"cpu": [
1091
1091
+
"riscv64"
1092
1092
+
],
1093
1093
+
"dev": true,
1094
1094
+
"license": "Apache-2.0",
1095
1095
+
"optional": true,
1096
1096
+
"os": [
1097
1097
+
"linux"
1098
1098
+
],
1099
1099
+
"engines": {
1100
1100
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1101
1101
+
},
1102
1102
+
"funding": {
1103
1103
+
"url": "https://opencollective.com/libvips"
1104
1104
+
},
1105
1105
+
"optionalDependencies": {
1106
1106
+
"@img/sharp-libvips-linux-riscv64": "1.2.4"
1107
1107
+
}
1108
1108
+
},
1109
1109
+
"node_modules/@img/sharp-linux-s390x": {
1110
1110
+
"version": "0.34.5",
1111
1111
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
1112
1112
+
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
1113
1113
+
"cpu": [
1114
1114
+
"s390x"
1115
1115
+
],
1116
1116
+
"dev": true,
1117
1117
+
"license": "Apache-2.0",
1118
1118
+
"optional": true,
1119
1119
+
"os": [
1120
1120
+
"linux"
1121
1121
+
],
1122
1122
+
"engines": {
1123
1123
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1124
1124
+
},
1125
1125
+
"funding": {
1126
1126
+
"url": "https://opencollective.com/libvips"
1127
1127
+
},
1128
1128
+
"optionalDependencies": {
1129
1129
+
"@img/sharp-libvips-linux-s390x": "1.2.4"
1130
1130
+
}
1131
1131
+
},
1132
1132
+
"node_modules/@img/sharp-linux-x64": {
1133
1133
+
"version": "0.34.5",
1134
1134
+
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
1135
1135
+
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
1136
1136
+
"cpu": [
1137
1137
+
"x64"
1138
1138
+
],
1139
1139
+
"dev": true,
1140
1140
+
"license": "Apache-2.0",
1141
1141
+
"optional": true,
1142
1142
+
"os": [
1143
1143
+
"linux"
1144
1144
+
],
1145
1145
+
"engines": {
1146
1146
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1147
1147
+
},
1148
1148
+
"funding": {
1149
1149
+
"url": "https://opencollective.com/libvips"
1150
1150
+
},
1151
1151
+
"optionalDependencies": {
1152
1152
+
"@img/sharp-libvips-linux-x64": "1.2.4"
1153
1153
+
}
1154
1154
+
},
1155
1155
+
"node_modules/@img/sharp-linuxmusl-arm64": {
1156
1156
+
"version": "0.34.5",
1157
1157
+
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
1158
1158
+
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
1159
1159
+
"cpu": [
1160
1160
+
"arm64"
1161
1161
+
],
1162
1162
+
"dev": true,
1163
1163
+
"license": "Apache-2.0",
1164
1164
+
"optional": true,
1165
1165
+
"os": [
1166
1166
+
"linux"
1167
1167
+
],
1168
1168
+
"engines": {
1169
1169
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1170
1170
+
},
1171
1171
+
"funding": {
1172
1172
+
"url": "https://opencollective.com/libvips"
1173
1173
+
},
1174
1174
+
"optionalDependencies": {
1175
1175
+
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
1176
1176
+
}
1177
1177
+
},
1178
1178
+
"node_modules/@img/sharp-linuxmusl-x64": {
1179
1179
+
"version": "0.34.5",
1180
1180
+
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
1181
1181
+
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
1182
1182
+
"cpu": [
1183
1183
+
"x64"
1184
1184
+
],
1185
1185
+
"dev": true,
1186
1186
+
"license": "Apache-2.0",
1187
1187
+
"optional": true,
1188
1188
+
"os": [
1189
1189
+
"linux"
1190
1190
+
],
1191
1191
+
"engines": {
1192
1192
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1193
1193
+
},
1194
1194
+
"funding": {
1195
1195
+
"url": "https://opencollective.com/libvips"
1196
1196
+
},
1197
1197
+
"optionalDependencies": {
1198
1198
+
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
1199
1199
+
}
1200
1200
+
},
1201
1201
+
"node_modules/@img/sharp-wasm32": {
1202
1202
+
"version": "0.34.5",
1203
1203
+
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
1204
1204
+
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
1205
1205
+
"cpu": [
1206
1206
+
"wasm32"
1207
1207
+
],
1208
1208
+
"dev": true,
1209
1209
+
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
1210
1210
+
"optional": true,
1211
1211
+
"dependencies": {
1212
1212
+
"@emnapi/runtime": "^1.7.0"
1213
1213
+
},
1214
1214
+
"engines": {
1215
1215
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1216
1216
+
},
1217
1217
+
"funding": {
1218
1218
+
"url": "https://opencollective.com/libvips"
1219
1219
+
}
1220
1220
+
},
1221
1221
+
"node_modules/@img/sharp-win32-arm64": {
1222
1222
+
"version": "0.34.5",
1223
1223
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
1224
1224
+
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
1225
1225
+
"cpu": [
1226
1226
+
"arm64"
1227
1227
+
],
1228
1228
+
"dev": true,
1229
1229
+
"license": "Apache-2.0 AND LGPL-3.0-or-later",
1230
1230
+
"optional": true,
1231
1231
+
"os": [
1232
1232
+
"win32"
1233
1233
+
],
1234
1234
+
"engines": {
1235
1235
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1236
1236
+
},
1237
1237
+
"funding": {
1238
1238
+
"url": "https://opencollective.com/libvips"
1239
1239
+
}
1240
1240
+
},
1241
1241
+
"node_modules/@img/sharp-win32-ia32": {
1242
1242
+
"version": "0.34.5",
1243
1243
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
1244
1244
+
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
1245
1245
+
"cpu": [
1246
1246
+
"ia32"
1247
1247
+
],
1248
1248
+
"dev": true,
1249
1249
+
"license": "Apache-2.0 AND LGPL-3.0-or-later",
1250
1250
+
"optional": true,
1251
1251
+
"os": [
1252
1252
+
"win32"
1253
1253
+
],
1254
1254
+
"engines": {
1255
1255
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1256
1256
+
},
1257
1257
+
"funding": {
1258
1258
+
"url": "https://opencollective.com/libvips"
1259
1259
+
}
1260
1260
+
},
1261
1261
+
"node_modules/@img/sharp-win32-x64": {
1262
1262
+
"version": "0.34.5",
1263
1263
+
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
1264
1264
+
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
1265
1265
+
"cpu": [
1266
1266
+
"x64"
1267
1267
+
],
1268
1268
+
"dev": true,
1269
1269
+
"license": "Apache-2.0 AND LGPL-3.0-or-later",
1270
1270
+
"optional": true,
1271
1271
+
"os": [
1272
1272
+
"win32"
1273
1273
+
],
1274
1274
+
"engines": {
1275
1275
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1276
1276
+
},
1277
1277
+
"funding": {
1278
1278
+
"url": "https://opencollective.com/libvips"
1279
1279
+
}
1280
1280
+
},
1281
1281
+
"node_modules/@jridgewell/resolve-uri": {
1282
1282
+
"version": "3.1.2",
1283
1283
+
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
1284
1284
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
1285
1285
+
"dev": true,
1286
1286
+
"license": "MIT",
1287
1287
+
"engines": {
1288
1288
+
"node": ">=6.0.0"
1289
1289
+
}
1290
1290
+
},
1291
1291
+
"node_modules/@jridgewell/sourcemap-codec": {
1292
1292
+
"version": "1.5.5",
1293
1293
+
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
1294
1294
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
1295
1295
+
"dev": true,
1296
1296
+
"license": "MIT"
1297
1297
+
},
1298
1298
+
"node_modules/@jridgewell/trace-mapping": {
1299
1299
+
"version": "0.3.9",
1300
1300
+
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
1301
1301
+
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
1302
1302
+
"dev": true,
1303
1303
+
"license": "MIT",
1304
1304
+
"dependencies": {
1305
1305
+
"@jridgewell/resolve-uri": "^3.0.3",
1306
1306
+
"@jridgewell/sourcemap-codec": "^1.4.10"
1307
1307
+
}
1308
1308
+
},
1309
1309
+
"node_modules/@noble/secp256k1": {
1310
1310
+
"version": "3.1.0",
1311
1311
+
"resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.1.0.tgz",
1312
1312
+
"integrity": "sha512-+F7iS7tUMaNGXcc9X3PjmjvuQnXEuSjCRNzVVA2xAcKXgCaP0dHYz4SFyt4FKNHef7sOP//xihowcySSS7PK9g==",
1313
1313
+
"license": "MIT",
1314
1314
+
"funding": {
1315
1315
+
"url": "https://paulmillr.com/funding/"
1316
1316
+
}
1317
1317
+
},
1318
1318
+
"node_modules/@poppinss/colors": {
1319
1319
+
"version": "4.1.6",
1320
1320
+
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
1321
1321
+
"integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==",
1322
1322
+
"dev": true,
1323
1323
+
"license": "MIT",
1324
1324
+
"dependencies": {
1325
1325
+
"kleur": "^4.1.5"
1326
1326
+
}
1327
1327
+
},
1328
1328
+
"node_modules/@poppinss/dumper": {
1329
1329
+
"version": "0.6.5",
1330
1330
+
"resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz",
1331
1331
+
"integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==",
1332
1332
+
"dev": true,
1333
1333
+
"license": "MIT",
1334
1334
+
"dependencies": {
1335
1335
+
"@poppinss/colors": "^4.1.5",
1336
1336
+
"@sindresorhus/is": "^7.0.2",
1337
1337
+
"supports-color": "^10.0.0"
1338
1338
+
}
1339
1339
+
},
1340
1340
+
"node_modules/@poppinss/exception": {
1341
1341
+
"version": "1.2.3",
1342
1342
+
"resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz",
1343
1343
+
"integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==",
1344
1344
+
"dev": true,
1345
1345
+
"license": "MIT"
1346
1346
+
},
1347
1347
+
"node_modules/@sindresorhus/is": {
1348
1348
+
"version": "7.2.0",
1349
1349
+
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
1350
1350
+
"integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==",
1351
1351
+
"dev": true,
1352
1352
+
"license": "MIT",
1353
1353
+
"engines": {
1354
1354
+
"node": ">=18"
1355
1355
+
},
1356
1356
+
"funding": {
1357
1357
+
"url": "https://github.com/sindresorhus/is?sponsor=1"
1358
1358
+
}
1359
1359
+
},
1360
1360
+
"node_modules/@speed-highlight/core": {
1361
1361
+
"version": "1.2.17",
1362
1362
+
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz",
1363
1363
+
"integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==",
1364
1364
+
"dev": true,
1365
1365
+
"license": "CC0-1.0"
1366
1366
+
},
1367
1367
+
"node_modules/@standard-schema/spec": {
1368
1368
+
"version": "1.1.0",
1369
1369
+
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1370
1370
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1371
1371
+
"license": "MIT"
1372
1372
+
},
1373
1373
+
"node_modules/blake3-wasm": {
1374
1374
+
"version": "2.1.5",
1375
1375
+
"resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz",
1376
1376
+
"integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==",
1377
1377
+
"dev": true,
1378
1378
+
"license": "MIT"
1379
1379
+
},
1380
1380
+
"node_modules/cookie": {
1381
1381
+
"version": "1.1.1",
1382
1382
+
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
1383
1383
+
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
1384
1384
+
"dev": true,
1385
1385
+
"license": "MIT",
1386
1386
+
"engines": {
1387
1387
+
"node": ">=18"
1388
1388
+
},
1389
1389
+
"funding": {
1390
1390
+
"type": "opencollective",
1391
1391
+
"url": "https://opencollective.com/express"
1392
1392
+
}
1393
1393
+
},
1394
1394
+
"node_modules/detect-libc": {
1395
1395
+
"version": "2.1.2",
1396
1396
+
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
1397
1397
+
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
1398
1398
+
"dev": true,
1399
1399
+
"license": "Apache-2.0",
1400
1400
+
"engines": {
1401
1401
+
"node": ">=8"
1402
1402
+
}
1403
1403
+
},
1404
1404
+
"node_modules/error-stack-parser-es": {
1405
1405
+
"version": "1.0.5",
1406
1406
+
"resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz",
1407
1407
+
"integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==",
1408
1408
+
"dev": true,
1409
1409
+
"license": "MIT",
1410
1410
+
"funding": {
1411
1411
+
"url": "https://github.com/sponsors/antfu"
1412
1412
+
}
1413
1413
+
},
1414
1414
+
"node_modules/esbuild": {
1415
1415
+
"version": "0.28.1",
1416
1416
+
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
1417
1417
+
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
1418
1418
+
"dev": true,
1419
1419
+
"hasInstallScript": true,
1420
1420
+
"license": "MIT",
1421
1421
+
"bin": {
1422
1422
+
"esbuild": "bin/esbuild"
1423
1423
+
},
1424
1424
+
"engines": {
1425
1425
+
"node": ">=18"
1426
1426
+
},
1427
1427
+
"optionalDependencies": {
1428
1428
+
"@esbuild/aix-ppc64": "0.28.1",
1429
1429
+
"@esbuild/android-arm": "0.28.1",
1430
1430
+
"@esbuild/android-arm64": "0.28.1",
1431
1431
+
"@esbuild/android-x64": "0.28.1",
1432
1432
+
"@esbuild/darwin-arm64": "0.28.1",
1433
1433
+
"@esbuild/darwin-x64": "0.28.1",
1434
1434
+
"@esbuild/freebsd-arm64": "0.28.1",
1435
1435
+
"@esbuild/freebsd-x64": "0.28.1",
1436
1436
+
"@esbuild/linux-arm": "0.28.1",
1437
1437
+
"@esbuild/linux-arm64": "0.28.1",
1438
1438
+
"@esbuild/linux-ia32": "0.28.1",
1439
1439
+
"@esbuild/linux-loong64": "0.28.1",
1440
1440
+
"@esbuild/linux-mips64el": "0.28.1",
1441
1441
+
"@esbuild/linux-ppc64": "0.28.1",
1442
1442
+
"@esbuild/linux-riscv64": "0.28.1",
1443
1443
+
"@esbuild/linux-s390x": "0.28.1",
1444
1444
+
"@esbuild/linux-x64": "0.28.1",
1445
1445
+
"@esbuild/netbsd-arm64": "0.28.1",
1446
1446
+
"@esbuild/netbsd-x64": "0.28.1",
1447
1447
+
"@esbuild/openbsd-arm64": "0.28.1",
1448
1448
+
"@esbuild/openbsd-x64": "0.28.1",
1449
1449
+
"@esbuild/openharmony-arm64": "0.28.1",
1450
1450
+
"@esbuild/sunos-x64": "0.28.1",
1451
1451
+
"@esbuild/win32-arm64": "0.28.1",
1452
1452
+
"@esbuild/win32-ia32": "0.28.1",
1453
1453
+
"@esbuild/win32-x64": "0.28.1"
1454
1454
+
}
1455
1455
+
},
1456
1456
+
"node_modules/esm-env": {
1457
1457
+
"version": "1.2.2",
1458
1458
+
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
1459
1459
+
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
1460
1460
+
"license": "MIT"
1461
1461
+
},
1462
1462
+
"node_modules/fsevents": {
1463
1463
+
"version": "2.3.3",
1464
1464
+
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
1465
1465
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
1466
1466
+
"dev": true,
1467
1467
+
"hasInstallScript": true,
1468
1468
+
"license": "MIT",
1469
1469
+
"optional": true,
1470
1470
+
"os": [
1471
1471
+
"darwin"
1472
1472
+
],
1473
1473
+
"engines": {
1474
1474
+
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
1475
1475
+
}
1476
1476
+
},
1477
1477
+
"node_modules/kleur": {
1478
1478
+
"version": "4.1.5",
1479
1479
+
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
1480
1480
+
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
1481
1481
+
"dev": true,
1482
1482
+
"license": "MIT",
1483
1483
+
"engines": {
1484
1484
+
"node": ">=6"
1485
1485
+
}
1486
1486
+
},
1487
1487
+
"node_modules/miniflare": {
1488
1488
+
"version": "4.20260625.0",
1489
1489
+
"resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260625.0.tgz",
1490
1490
+
"integrity": "sha512-3kKXwRUObJsnBYPBgR0NiNZYKF/yv8GFyha1cx2EeAEraxNODgRVcyeRo+F1ok1tg5Mg7iUpOWSkknQTHuFhwA==",
1491
1491
+
"dev": true,
1492
1492
+
"license": "MIT",
1493
1493
+
"dependencies": {
1494
1494
+
"@cspotcode/source-map-support": "0.8.1",
1495
1495
+
"sharp": "0.34.5",
1496
1496
+
"undici": "7.28.0",
1497
1497
+
"workerd": "1.20260625.1",
1498
1498
+
"ws": "8.21.0",
1499
1499
+
"youch": "4.1.0-beta.10"
1500
1500
+
},
1501
1501
+
"bin": {
1502
1502
+
"miniflare": "bootstrap.js"
1503
1503
+
},
1504
1504
+
"engines": {
1505
1505
+
"node": ">=22.0.0"
1506
1506
+
}
1507
1507
+
},
1508
1508
+
"node_modules/path-to-regexp": {
1509
1509
+
"version": "6.3.0",
1510
1510
+
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
1511
1511
+
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
1512
1512
+
"dev": true,
1513
1513
+
"license": "MIT"
1514
1514
+
},
1515
1515
+
"node_modules/pathe": {
1516
1516
+
"version": "2.0.3",
1517
1517
+
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
1518
1518
+
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
1519
1519
+
"dev": true,
1520
1520
+
"license": "MIT"
1521
1521
+
},
1522
1522
+
"node_modules/semver": {
1523
1523
+
"version": "7.8.5",
1524
1524
+
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
1525
1525
+
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
1526
1526
+
"dev": true,
1527
1527
+
"license": "ISC",
1528
1528
+
"bin": {
1529
1529
+
"semver": "bin/semver.js"
1530
1530
+
},
1531
1531
+
"engines": {
1532
1532
+
"node": ">=10"
1533
1533
+
}
1534
1534
+
},
1535
1535
+
"node_modules/sharp": {
1536
1536
+
"version": "0.34.5",
1537
1537
+
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
1538
1538
+
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
1539
1539
+
"dev": true,
1540
1540
+
"hasInstallScript": true,
1541
1541
+
"license": "Apache-2.0",
1542
1542
+
"dependencies": {
1543
1543
+
"@img/colour": "^1.0.0",
1544
1544
+
"detect-libc": "^2.1.2",
1545
1545
+
"semver": "^7.7.3"
1546
1546
+
},
1547
1547
+
"engines": {
1548
1548
+
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
1549
1549
+
},
1550
1550
+
"funding": {
1551
1551
+
"url": "https://opencollective.com/libvips"
1552
1552
+
},
1553
1553
+
"optionalDependencies": {
1554
1554
+
"@img/sharp-darwin-arm64": "0.34.5",
1555
1555
+
"@img/sharp-darwin-x64": "0.34.5",
1556
1556
+
"@img/sharp-libvips-darwin-arm64": "1.2.4",
1557
1557
+
"@img/sharp-libvips-darwin-x64": "1.2.4",
1558
1558
+
"@img/sharp-libvips-linux-arm": "1.2.4",
1559
1559
+
"@img/sharp-libvips-linux-arm64": "1.2.4",
1560
1560
+
"@img/sharp-libvips-linux-ppc64": "1.2.4",
1561
1561
+
"@img/sharp-libvips-linux-riscv64": "1.2.4",
1562
1562
+
"@img/sharp-libvips-linux-s390x": "1.2.4",
1563
1563
+
"@img/sharp-libvips-linux-x64": "1.2.4",
1564
1564
+
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
1565
1565
+
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
1566
1566
+
"@img/sharp-linux-arm": "0.34.5",
1567
1567
+
"@img/sharp-linux-arm64": "0.34.5",
1568
1568
+
"@img/sharp-linux-ppc64": "0.34.5",
1569
1569
+
"@img/sharp-linux-riscv64": "0.34.5",
1570
1570
+
"@img/sharp-linux-s390x": "0.34.5",
1571
1571
+
"@img/sharp-linux-x64": "0.34.5",
1572
1572
+
"@img/sharp-linuxmusl-arm64": "0.34.5",
1573
1573
+
"@img/sharp-linuxmusl-x64": "0.34.5",
1574
1574
+
"@img/sharp-wasm32": "0.34.5",
1575
1575
+
"@img/sharp-win32-arm64": "0.34.5",
1576
1576
+
"@img/sharp-win32-ia32": "0.34.5",
1577
1577
+
"@img/sharp-win32-x64": "0.34.5"
1578
1578
+
}
1579
1579
+
},
1580
1580
+
"node_modules/supports-color": {
1581
1581
+
"version": "10.2.2",
1582
1582
+
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
1583
1583
+
"integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==",
1584
1584
+
"dev": true,
1585
1585
+
"license": "MIT",
1586
1586
+
"engines": {
1587
1587
+
"node": ">=18"
1588
1588
+
},
1589
1589
+
"funding": {
1590
1590
+
"url": "https://github.com/chalk/supports-color?sponsor=1"
1591
1591
+
}
1592
1592
+
},
1593
1593
+
"node_modules/tslib": {
1594
1594
+
"version": "2.8.1",
1595
1595
+
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
1596
1596
+
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
1597
1597
+
"dev": true,
1598
1598
+
"license": "0BSD",
1599
1599
+
"optional": true
1600
1600
+
},
1601
1601
+
"node_modules/undici": {
1602
1602
+
"version": "7.28.0",
1603
1603
+
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz",
1604
1604
+
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==",
1605
1605
+
"dev": true,
1606
1606
+
"license": "MIT",
1607
1607
+
"engines": {
1608
1608
+
"node": ">=20.18.1"
1609
1609
+
}
1610
1610
+
},
1611
1611
+
"node_modules/unenv": {
1612
1612
+
"version": "2.0.0-rc.24",
1613
1613
+
"resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz",
1614
1614
+
"integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==",
1615
1615
+
"dev": true,
1616
1616
+
"license": "MIT",
1617
1617
+
"dependencies": {
1618
1618
+
"pathe": "^2.0.3"
1619
1619
+
}
1620
1620
+
},
1621
1621
+
"node_modules/unicode-segmenter": {
1622
1622
+
"version": "0.14.5",
1623
1623
+
"resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz",
1624
1624
+
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==",
1625
1625
+
"license": "MIT"
1626
1626
+
},
1627
1627
+
"node_modules/valibot": {
1628
1628
+
"version": "1.4.2",
1629
1629
+
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
1630
1630
+
"integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
1631
1631
+
"license": "MIT",
1632
1632
+
"peerDependencies": {
1633
1633
+
"typescript": ">=5"
1634
1634
+
},
1635
1635
+
"peerDependenciesMeta": {
1636
1636
+
"typescript": {
1637
1637
+
"optional": true
1638
1638
+
}
1639
1639
+
}
1640
1640
+
},
1641
1641
+
"node_modules/workerd": {
1642
1642
+
"version": "1.20260625.1",
1643
1643
+
"resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260625.1.tgz",
1644
1644
+
"integrity": "sha512-GApQvFX52SDM6L4u0+RRnUDB1wJOnEwoXjinkmOPtIyofWBxrlZckdegJSYc1leg++lLZ3+DQ4zMVmBqYVtzfA==",
1645
1645
+
"dev": true,
1646
1646
+
"hasInstallScript": true,
1647
1647
+
"license": "Apache-2.0",
1648
1648
+
"bin": {
1649
1649
+
"workerd": "bin/workerd"
1650
1650
+
},
1651
1651
+
"engines": {
1652
1652
+
"node": ">=16"
1653
1653
+
},
1654
1654
+
"optionalDependencies": {
1655
1655
+
"@cloudflare/workerd-darwin-64": "1.20260625.1",
1656
1656
+
"@cloudflare/workerd-darwin-arm64": "1.20260625.1",
1657
1657
+
"@cloudflare/workerd-linux-64": "1.20260625.1",
1658
1658
+
"@cloudflare/workerd-linux-arm64": "1.20260625.1",
1659
1659
+
"@cloudflare/workerd-windows-64": "1.20260625.1"
1660
1660
+
}
1661
1661
+
},
1662
1662
+
"node_modules/wrangler": {
1663
1663
+
"version": "4.105.0",
1664
1664
+
"resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.105.0.tgz",
1665
1665
+
"integrity": "sha512-7dXFH6OLj1Fv0y6ZeRPUxFTkp+duWD7/xxVi/1c0vfOeEYwIFKWB7cdqnY05DvY1Ta3BnqAwRkXfLs8PDj538g==",
1666
1666
+
"dev": true,
1667
1667
+
"license": "MIT OR Apache-2.0",
1668
1668
+
"dependencies": {
1669
1669
+
"@cloudflare/kv-asset-handler": "0.5.0",
1670
1670
+
"@cloudflare/unenv-preset": "2.16.1",
1671
1671
+
"blake3-wasm": "2.1.5",
1672
1672
+
"esbuild": "0.28.1",
1673
1673
+
"miniflare": "4.20260625.0",
1674
1674
+
"path-to-regexp": "6.3.0",
1675
1675
+
"unenv": "2.0.0-rc.24",
1676
1676
+
"workerd": "1.20260625.1"
1677
1677
+
},
1678
1678
+
"bin": {
1679
1679
+
"cf-wrangler": "bin/cf-wrangler.js",
1680
1680
+
"wrangler": "bin/wrangler.js",
1681
1681
+
"wrangler2": "bin/wrangler.js"
1682
1682
+
},
1683
1683
+
"engines": {
1684
1684
+
"node": ">=22.0.0"
1685
1685
+
},
1686
1686
+
"optionalDependencies": {
1687
1687
+
"fsevents": "2.3.3"
1688
1688
+
},
1689
1689
+
"peerDependencies": {
1690
1690
+
"@cloudflare/workers-types": "^4.20260625.1"
1691
1691
+
},
1692
1692
+
"peerDependenciesMeta": {
1693
1693
+
"@cloudflare/workers-types": {
1694
1694
+
"optional": true
1695
1695
+
}
1696
1696
+
}
1697
1697
+
},
1698
1698
+
"node_modules/ws": {
1699
1699
+
"version": "8.21.0",
1700
1700
+
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
1701
1701
+
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
1702
1702
+
"dev": true,
1703
1703
+
"license": "MIT",
1704
1704
+
"engines": {
1705
1705
+
"node": ">=10.0.0"
1706
1706
+
},
1707
1707
+
"peerDependencies": {
1708
1708
+
"bufferutil": "^4.0.1",
1709
1709
+
"utf-8-validate": ">=5.0.2"
1710
1710
+
},
1711
1711
+
"peerDependenciesMeta": {
1712
1712
+
"bufferutil": {
1713
1713
+
"optional": true
1714
1714
+
},
1715
1715
+
"utf-8-validate": {
1716
1716
+
"optional": true
1717
1717
+
}
1718
1718
+
}
1719
1719
+
},
1720
1720
+
"node_modules/youch": {
1721
1721
+
"version": "4.1.0-beta.10",
1722
1722
+
"resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz",
1723
1723
+
"integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==",
1724
1724
+
"dev": true,
1725
1725
+
"license": "MIT",
1726
1726
+
"dependencies": {
1727
1727
+
"@poppinss/colors": "^4.1.5",
1728
1728
+
"@poppinss/dumper": "^0.6.4",
1729
1729
+
"@speed-highlight/core": "^1.2.7",
1730
1730
+
"cookie": "^1.0.2",
1731
1731
+
"youch-core": "^0.3.3"
1732
1732
+
}
1733
1733
+
},
1734
1734
+
"node_modules/youch-core": {
1735
1735
+
"version": "0.3.3",
1736
1736
+
"resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz",
1737
1737
+
"integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==",
1738
1738
+
"dev": true,
1739
1739
+
"license": "MIT",
1740
1740
+
"dependencies": {
1741
1741
+
"@poppinss/exception": "^1.2.2",
1742
1742
+
"error-stack-parser-es": "^1.0.5"
1743
1743
+
}
1744
1744
+
}
1745
1745
+
}
1746
1746
+
}
···
1
1
+
{
2
2
+
"name": "pensieve",
3
3
+
"version": "0.1.0",
4
4
+
"private": true,
5
5
+
"type": "module",
6
6
+
"scripts": {
7
7
+
"dev": "wrangler dev",
8
8
+
"deploy": "wrangler deploy",
9
9
+
"dry-run": "wrangler deploy --dry-run"
10
10
+
},
11
11
+
"devDependencies": {
12
12
+
"wrangler": "^4.99.0"
13
13
+
},
14
14
+
"dependencies": {
15
15
+
"@atcute/atproto": "^4.0.3",
16
16
+
"@atcute/car": "^6.0.2",
17
17
+
"@atcute/cbor": "^2.3.5",
18
18
+
"@atcute/cid": "^2.4.2",
19
19
+
"@atcute/client": "^5.1.0",
20
20
+
"@atcute/identity-resolver": "^2.0.1",
21
21
+
"@atcute/mst": "^1.0.2",
22
22
+
"@atcute/repo": "^1.0.2"
23
23
+
}
24
24
+
}
···
1
1
+
#!/usr/bin/env python3
2
2
+
"""Render the Pensieve demo as a user-facing inspection page."""
3
3
+
4
4
+
from __future__ import annotations
5
5
+
6
6
+
import html
7
7
+
import json
8
8
+
from pathlib import Path
9
9
+
10
10
+
11
11
+
BASE = Path("/tmp/pensieve-demo")
12
12
+
13
13
+
14
14
+
def h(value: object) -> str:
15
15
+
return html.escape(str(value), quote=True)
16
16
+
17
17
+
18
18
+
def load(name: str):
19
19
+
return json.loads((BASE / name).read_text())
20
20
+
21
21
+
22
22
+
def stat(label: str, value: object) -> str:
23
23
+
return f"<div class='metric'><b>{h(value)}</b><span>{h(label)}</span></div>"
24
24
+
25
25
+
26
26
+
def result(row: dict) -> str:
27
27
+
text = row.get("text", "")
28
28
+
if len(text) > 1200:
29
29
+
text = text[:1200] + "\n..."
30
30
+
return f"""
31
31
+
<article class="result">
32
32
+
<div class="result-top">
33
33
+
<code>{h(row.get("collection", ""))}</code>
34
34
+
<span>distance {float(row.get("$dist", 0)):.3f}</span>
35
35
+
</div>
36
36
+
<a href="https://bsky.app/profile/{h(row.get("authorDid", ""))}/post/{h(row.get("uri", "").rsplit("/", 1)[-1])}">{h(row.get("uri", ""))}</a>
37
37
+
<pre>{h(text)}</pre>
38
38
+
</article>
39
39
+
"""
40
40
+
41
41
+
42
42
+
def query_section(title: str, filename: str) -> str:
43
43
+
data = load(filename)
44
44
+
rows = "\n".join(result(row) for row in data["rows"])
45
45
+
return f"""
46
46
+
<section>
47
47
+
<div class="section-head">
48
48
+
<h2>{h(title)}</h2>
49
49
+
<p>Semantic query: <code>{h(data["query"])}</code></p>
50
50
+
</div>
51
51
+
{rows}
52
52
+
</section>
53
53
+
"""
54
54
+
55
55
+
56
56
+
def main() -> None:
57
57
+
manifest = load("manifest.json")
58
58
+
top_collections = "".join(
59
59
+
f"<li><span>{h(row['collection'])}</span><b>{h(row['count'])}</b></li>"
60
60
+
for row in manifest["topCollections"][:10]
61
61
+
)
62
62
+
63
63
+
page = f"""<!doctype html>
64
64
+
<html lang="en">
65
65
+
<head>
66
66
+
<meta charset="utf-8">
67
67
+
<meta name="viewport" content="width=device-width, initial-scale=1">
68
68
+
<title>Pensieve</title>
69
69
+
<style>
70
70
+
:root {{
71
71
+
color-scheme: light dark;
72
72
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
73
73
+
background: #f6f5f1;
74
74
+
color: #181816;
75
75
+
}}
76
76
+
body {{ margin: 0; background: #f6f5f1; }}
77
77
+
main {{ max-width: 1180px; margin: 0 auto; padding: 30px; }}
78
78
+
header {{ border-bottom: 1px solid #d8d5cc; padding: 8px 0 24px; }}
79
79
+
h1 {{ margin: 0; font-size: 42px; line-height: 1; letter-spacing: 0; }}
80
80
+
h2 {{ margin: 0; font-size: 21px; }}
81
81
+
p {{ margin: 8px 0 0; color: #5c5a52; }}
82
82
+
code {{ font-size: 12px; color: #57544e; }}
83
83
+
.metrics {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin: 22px 0; }}
84
84
+
.metric, .panel, .result {{ background: #fff; border: 1px solid #dedbd2; border-radius: 8px; }}
85
85
+
.metric {{ padding: 14px; }}
86
86
+
.metric b {{ display: block; font-size: 24px; }}
87
87
+
.metric span {{ display: block; margin-top: 3px; color: #6b675f; font-size: 12px; text-transform: uppercase; }}
88
88
+
.layout {{ display: grid; grid-template-columns: minmax(260px, 340px) 1fr; gap: 18px; align-items: start; }}
89
89
+
.panel {{ padding: 16px; position: sticky; top: 20px; }}
90
90
+
.panel h2 {{ margin-bottom: 12px; }}
91
91
+
ul {{ list-style: none; padding: 0; margin: 0; }}
92
92
+
li {{ display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-top: 1px solid #ece9df; }}
93
93
+
li:first-child {{ border-top: 0; }}
94
94
+
li span {{ overflow-wrap: anywhere; }}
95
95
+
section {{ margin-bottom: 30px; }}
96
96
+
.section-head {{ margin-bottom: 12px; }}
97
97
+
.result {{ padding: 14px; margin: 10px 0; }}
98
98
+
.result-top {{ display: flex; justify-content: space-between; gap: 14px; align-items: baseline; }}
99
99
+
.result-top span {{ color: #177062; font-variant-numeric: tabular-nums; }}
100
100
+
a {{ display: inline-block; margin-top: 8px; color: #17695d; overflow-wrap: anywhere; }}
101
101
+
pre {{ white-space: pre-wrap; overflow-wrap: anywhere; font-size: 13px; line-height: 1.45; margin: 10px 0 0; }}
102
102
+
.files {{ margin-top: 16px; color: #5c5a52; font-size: 13px; }}
103
103
+
@media (max-width: 820px) {{ .layout {{ grid-template-columns: 1fr; }} .panel {{ position: static; }} }}
104
104
+
@media (prefers-color-scheme: dark) {{
105
105
+
:root, body {{ background: #171717; color: #f2efe7; }}
106
106
+
header {{ border-color: #383630; }}
107
107
+
p, .files {{ color: #b9b3a6; }}
108
108
+
.metric, .panel, .result {{ background: #20201f; border-color: #383630; }}
109
109
+
code {{ color: #bdb7aa; }}
110
110
+
li {{ border-color: #35332d; }}
111
111
+
a, .result-top span {{ color: #65b8a8; }}
112
112
+
}}
113
113
+
</style>
114
114
+
</head>
115
115
+
<body>
116
116
+
<main>
117
117
+
<header>
118
118
+
<h1>Pensieve</h1>
119
119
+
<p>ATProto memory search over an extracted repository corpus.</p>
120
120
+
</header>
121
121
+
<div class="metrics">
122
122
+
{stat("repo records", f"{manifest['records']:,}")}
123
123
+
{stat("extracted memories", f"{manifest['items']:,}")}
124
124
+
{stat("indexed sample", "200")}
125
125
+
{stat("CAR size", f"{manifest['carBytes']:,} bytes")}
126
126
+
</div>
127
127
+
<div class="layout">
128
128
+
<aside class="panel">
129
129
+
<h2>Corpus</h2>
130
130
+
<p><code>{h(manifest['did'])}</code></p>
131
131
+
<p>{h(manifest['pds'])}</p>
132
132
+
<h2 style="margin-top:22px">Top Collections</h2>
133
133
+
<ul>{top_collections}</ul>
134
134
+
<p class="files">Files: <code>corpus.ndjson</code>, <code>manifest.json</code>, and query result JSON in <code>/tmp/pensieve-demo</code>.</p>
135
135
+
</aside>
136
136
+
<div>
137
137
+
{query_section("Structured Outputs Lineage", "query-structured-outputs.json")}
138
138
+
{query_section("ATProto Repository Search", "query-atproto-search.json")}
139
139
+
</div>
140
140
+
</div>
141
141
+
</main>
142
142
+
</body>
143
143
+
</html>
144
144
+
"""
145
145
+
(BASE / "search.html").write_text(page)
146
146
+
147
147
+
148
148
+
if __name__ == "__main__":
149
149
+
main()
···
1
1
+
#!/usr/bin/env python3
2
2
+
"""Embed Pensieve NDJSON with Voyage and store/query it in Turbopuffer."""
3
3
+
4
4
+
from __future__ import annotations
5
5
+
6
6
+
import argparse
7
7
+
import hashlib
8
8
+
import json
9
9
+
import os
10
10
+
import sys
11
11
+
import urllib.error
12
12
+
import urllib.request
13
13
+
from pathlib import Path
14
14
+
from typing import Any
15
15
+
16
16
+
17
17
+
VOYAGE_URL = "https://api.voyageai.com/v1/embeddings"
18
18
+
TPUF_BASE = "https://api.turbopuffer.com/v2/namespaces"
19
19
+
MODEL = "voyage-4-lite"
20
20
+
DIMENSIONS = 1024
21
21
+
22
22
+
23
23
+
def env(name: str) -> str:
24
24
+
value = os.environ.get(name)
25
25
+
if not value:
26
26
+
raise SystemExit(f"missing {name}")
27
27
+
return value
28
28
+
29
29
+
30
30
+
def post_json(url: str, body: dict[str, Any], headers: dict[str, str]) -> dict[str, Any]:
31
31
+
payload = json.dumps(body, separators=(",", ":")).encode()
32
32
+
request = urllib.request.Request(
33
33
+
url,
34
34
+
data=payload,
35
35
+
headers={"content-type": "application/json", **headers},
36
36
+
method="POST",
37
37
+
)
38
38
+
try:
39
39
+
with urllib.request.urlopen(request, timeout=60) as response:
40
40
+
return json.loads(response.read())
41
41
+
except urllib.error.HTTPError as exc:
42
42
+
detail = exc.read().decode(errors="replace")[:500]
43
43
+
raise SystemExit(f"{url} failed: HTTP {exc.code}: {detail}") from exc
44
44
+
45
45
+
46
46
+
def embed(texts: list[str], input_type: str) -> list[list[float]]:
47
47
+
voyage_key = env("VOYAGE_API_KEY")
48
48
+
response = post_json(
49
49
+
VOYAGE_URL,
50
50
+
{
51
51
+
"model": MODEL,
52
52
+
"input_type": input_type,
53
53
+
"output_dimension": DIMENSIONS,
54
54
+
"input": texts,
55
55
+
},
56
56
+
{"authorization": f"Bearer {voyage_key}"},
57
57
+
)
58
58
+
return [row["embedding"] for row in response["data"]]
59
59
+
60
60
+
61
61
+
def row_id(uri: str) -> str:
62
62
+
return hashlib.sha256(uri.encode()).hexdigest()[:32]
63
63
+
64
64
+
65
65
+
def load_items(path: Path, limit: int) -> list[dict[str, Any]]:
66
66
+
items: list[dict[str, Any]] = []
67
67
+
with path.open() as f:
68
68
+
for line in f:
69
69
+
item = json.loads(line)
70
70
+
if item.get("kind") != "text" or not item.get("text"):
71
71
+
continue
72
72
+
items.append(item)
73
73
+
if len(items) >= limit:
74
74
+
break
75
75
+
return items
76
76
+
77
77
+
78
78
+
def chunks(items: list[dict[str, Any]], size: int):
79
79
+
for i in range(0, len(items), size):
80
80
+
yield items[i : i + size]
81
81
+
82
82
+
83
83
+
def tpuf_url(namespace: str, suffix: str = "") -> str:
84
84
+
return f"{TPUF_BASE}/{namespace}{suffix}"
85
85
+
86
86
+
87
87
+
def upsert(namespace: str, items: list[dict[str, Any]], batch_size: int) -> None:
88
88
+
tpuf_key = env("TURBOPUFFER_API_KEY")
89
89
+
total = 0
90
90
+
for batch in chunks(items, batch_size):
91
91
+
vectors = embed([item["text"] for item in batch], "document")
92
92
+
rows = []
93
93
+
for item, vector in zip(batch, vectors, strict=True):
94
94
+
rows.append(
95
95
+
{
96
96
+
"id": row_id(item["uri"]),
97
97
+
"vector": vector,
98
98
+
"uri": item["uri"],
99
99
+
"cid": item["cid"],
100
100
+
"collection": item["collection"],
101
101
+
"authorDid": item["authorDid"],
102
102
+
"createdAt": item.get("createdAt", ""),
103
103
+
"text": item["text"][:800],
104
104
+
}
105
105
+
)
106
106
+
post_json(
107
107
+
tpuf_url(namespace),
108
108
+
{"distance_metric": "cosine_distance", "upsert_rows": rows},
109
109
+
{"authorization": f"Bearer {tpuf_key}"},
110
110
+
)
111
111
+
total += len(rows)
112
112
+
print(f"upserted {total}/{len(items)}", file=sys.stderr)
113
113
+
114
114
+
115
115
+
def query(namespace: str, text: str, top_k: int) -> list[dict[str, Any]]:
116
116
+
tpuf_key = env("TURBOPUFFER_API_KEY")
117
117
+
vector = embed([text], "query")[0]
118
118
+
response = post_json(
119
119
+
tpuf_url(namespace, "/query"),
120
120
+
{
121
121
+
"rank_by": ["vector", "ANN", vector],
122
122
+
"top_k": top_k,
123
123
+
"include_attributes": ["uri", "collection", "authorDid", "createdAt", "text"],
124
124
+
},
125
125
+
{"authorization": f"Bearer {tpuf_key}"},
126
126
+
)
127
127
+
return response.get("rows", [])
128
128
+
129
129
+
130
130
+
def cmd_index(args: argparse.Namespace) -> None:
131
131
+
items = load_items(Path(args.corpus), args.limit)
132
132
+
if not items:
133
133
+
raise SystemExit("no text items found")
134
134
+
upsert(args.namespace, items, args.batch_size)
135
135
+
print(json.dumps({"namespace": args.namespace, "indexed": len(items)}, indent=2))
136
136
+
137
137
+
138
138
+
def cmd_query(args: argparse.Namespace) -> None:
139
139
+
rows = query(args.namespace, args.text, args.top_k)
140
140
+
print(json.dumps({"namespace": args.namespace, "query": args.text, "rows": rows}, indent=2))
141
141
+
142
142
+
143
143
+
def main() -> None:
144
144
+
parser = argparse.ArgumentParser()
145
145
+
sub = parser.add_subparsers(dest="command", required=True)
146
146
+
147
147
+
index = sub.add_parser("index")
148
148
+
index.add_argument("--corpus", required=True)
149
149
+
index.add_argument("--namespace", default=os.environ.get("TURBOPUFFER_NAMESPACE", "pensieve-demo"))
150
150
+
index.add_argument("--limit", type=int, default=200)
151
151
+
index.add_argument("--batch-size", type=int, default=32)
152
152
+
index.set_defaults(func=cmd_index)
153
153
+
154
154
+
q = sub.add_parser("query")
155
155
+
q.add_argument("--namespace", default=os.environ.get("TURBOPUFFER_NAMESPACE", "pensieve-demo"))
156
156
+
q.add_argument("--text", required=True)
157
157
+
q.add_argument("--top-k", type=int, default=5)
158
158
+
q.set_defaults(func=cmd_query)
159
159
+
160
160
+
args = parser.parse_args()
161
161
+
args.func(args)
162
162
+
163
163
+
164
164
+
if __name__ == "__main__":
165
165
+
main()
···
1
1
+
const std = @import("std");
2
2
+
const pensieve = @import("pensieve");
3
3
+
const zat = @import("zat");
4
4
+
5
5
+
const usage =
6
6
+
\\usage:
7
7
+
\\ pensieve extract --actor <handle-or-did> [--media] [--out <path>]
8
8
+
\\ pensieve demo --actor <handle-or-did> [--media] [--dir <path>]
9
9
+
\\
10
10
+
\\examples:
11
11
+
\\ pensieve extract --actor zzstoatzz.io
12
12
+
\\ pensieve extract --actor did:plc:... --media --out corpus.ndjson
13
13
+
\\ pensieve demo --actor zzstoatzz.io --dir /tmp/pensieve-demo
14
14
+
\\
15
15
+
;
16
16
+
17
17
+
pub fn main(init: std.process.Init) !void {
18
18
+
const args = try init.minimal.args.toSlice(init.arena.allocator());
19
19
+
20
20
+
var arena_impl = std.heap.ArenaAllocator.init(init.gpa);
21
21
+
defer arena_impl.deinit();
22
22
+
const arena = arena_impl.allocator();
23
23
+
24
24
+
if (args.len < 2) return fail(usage);
25
25
+
const cmd = args[1];
26
26
+
const is_extract = std.mem.eql(u8, cmd, "extract");
27
27
+
const is_demo = std.mem.eql(u8, cmd, "demo");
28
28
+
if (!is_extract and !is_demo) return fail(usage);
29
29
+
30
30
+
var actor: ?[]const u8 = null;
31
31
+
var out_path: ?[]const u8 = null;
32
32
+
var demo_dir: []const u8 = "/tmp/pensieve-demo";
33
33
+
var options: pensieve.ExtractOptions = .{};
34
34
+
35
35
+
var i: usize = 2;
36
36
+
while (i < args.len) : (i += 1) {
37
37
+
const arg = args[i];
38
38
+
if (std.mem.eql(u8, arg, "--actor")) {
39
39
+
i += 1;
40
40
+
if (i >= args.len) return fail("missing value for --actor\n");
41
41
+
actor = args[i];
42
42
+
} else if (std.mem.eql(u8, arg, "--media")) {
43
43
+
options.include_media_refs = true;
44
44
+
} else if (std.mem.eql(u8, arg, "--out")) {
45
45
+
i += 1;
46
46
+
if (i >= args.len) return fail("missing value for --out\n");
47
47
+
out_path = args[i];
48
48
+
} else if (std.mem.eql(u8, arg, "--dir")) {
49
49
+
i += 1;
50
50
+
if (i >= args.len) return fail("missing value for --dir\n");
51
51
+
demo_dir = args[i];
52
52
+
} else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
53
53
+
return fail(usage);
54
54
+
} else {
55
55
+
return fail(usage);
56
56
+
}
57
57
+
}
58
58
+
59
59
+
const target = actor orelse return fail("missing --actor\n");
60
60
+
61
61
+
const io = init.io;
62
62
+
63
63
+
var transport = zat.HttpTransport.init(io, arena);
64
64
+
const identity = try pensieve.pds.resolveIdentity(arena, &transport, target);
65
65
+
var opened = try pensieve.repo_walk.openRepo(arena, io, identity.pds, identity.did);
66
66
+
defer opened.close(io);
67
67
+
68
68
+
const result = try pensieve.repo_walk.walkOpened(arena, &opened, identity.did, pensieve.filterFromOptions(options));
69
69
+
const items = try pensieve.extractRecords(arena, identity.did, result.records, options);
70
70
+
71
71
+
if (is_demo) {
72
72
+
try writeDemo(arena, io, demo_dir, target, identity, result, items, options);
73
73
+
} else {
74
74
+
var file: std.Io.File = if (out_path) |path|
75
75
+
try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true, .truncate = true })
76
76
+
else
77
77
+
std.Io.File.stdout();
78
78
+
defer if (out_path != null) file.close(io);
79
79
+
80
80
+
var buf: [64 * 1024]u8 = undefined;
81
81
+
var writer = std.Io.File.Writer.initStreaming(file, io, &buf);
82
82
+
try writeNdjson(&writer.interface, items);
83
83
+
try writer.end();
84
84
+
}
85
85
+
86
86
+
std.log.info("extracted {d} items from {d} records ({d} CAR bytes)", .{
87
87
+
items.len,
88
88
+
result.records.len,
89
89
+
result.car_bytes,
90
90
+
});
91
91
+
}
92
92
+
93
93
+
fn writeDemo(
94
94
+
arena: std.mem.Allocator,
95
95
+
io: std.Io,
96
96
+
dir_path: []const u8,
97
97
+
actor: []const u8,
98
98
+
identity: pensieve.pds.Identity,
99
99
+
result: pensieve.repo_walk.WalkResult,
100
100
+
items: []const pensieve.EmbeddableItem,
101
101
+
options: pensieve.ExtractOptions,
102
102
+
) !void {
103
103
+
std.Io.Dir.createDirPath(std.Io.Dir.cwd(), io, dir_path) catch |err| switch (err) {
104
104
+
error.PathAlreadyExists => {},
105
105
+
else => return err,
106
106
+
};
107
107
+
108
108
+
const corpus_path = try std.fs.path.join(arena, &.{ dir_path, "corpus.ndjson" });
109
109
+
const manifest_path = try std.fs.path.join(arena, &.{ dir_path, "manifest.json" });
110
110
+
const report_path = try std.fs.path.join(arena, &.{ dir_path, "index.html" });
111
111
+
112
112
+
{
113
113
+
var file = try std.Io.Dir.createFileAbsolute(io, corpus_path, .{ .read = true, .truncate = true });
114
114
+
defer file.close(io);
115
115
+
var buf: [64 * 1024]u8 = undefined;
116
116
+
var writer = std.Io.File.Writer.initStreaming(file, io, &buf);
117
117
+
try writeNdjson(&writer.interface, items);
118
118
+
try writer.end();
119
119
+
}
120
120
+
121
121
+
const summary = try summarize(arena, items);
122
122
+
try writeManifest(io, manifest_path, actor, identity, result, items, summary, options);
123
123
+
try writeReport(io, report_path, actor, identity, result, items, summary, corpus_path, manifest_path, options);
124
124
+
125
125
+
std.debug.print("Pensieve demo ready:\n report: {s}\n corpus: {s}\n manifest: {s}\n", .{
126
126
+
report_path,
127
127
+
corpus_path,
128
128
+
manifest_path,
129
129
+
});
130
130
+
}
131
131
+
132
132
+
const CollectionCount = struct {
133
133
+
name: []const u8,
134
134
+
count: usize,
135
135
+
};
136
136
+
137
137
+
const Summary = struct {
138
138
+
text_items: usize = 0,
139
139
+
image_refs: usize = 0,
140
140
+
video_refs: usize = 0,
141
141
+
collections: []CollectionCount,
142
142
+
};
143
143
+
144
144
+
fn summarize(arena: std.mem.Allocator, items: []const pensieve.EmbeddableItem) !Summary {
145
145
+
var collections: std.ArrayList(CollectionCount) = .empty;
146
146
+
var summary: Summary = .{ .collections = &.{} };
147
147
+
for (items) |item| {
148
148
+
switch (item.kind) {
149
149
+
.text => summary.text_items += 1,
150
150
+
.image_ref => summary.image_refs += 1,
151
151
+
.video_ref => summary.video_refs += 1,
152
152
+
}
153
153
+
var found = false;
154
154
+
for (collections.items) |*entry| {
155
155
+
if (std.mem.eql(u8, entry.name, item.collection)) {
156
156
+
entry.count += 1;
157
157
+
found = true;
158
158
+
break;
159
159
+
}
160
160
+
}
161
161
+
if (!found) try collections.append(arena, .{ .name = item.collection, .count = 1 });
162
162
+
}
163
163
+
std.mem.sort(CollectionCount, collections.items, {}, struct {
164
164
+
fn lessThan(_: void, a: CollectionCount, b: CollectionCount) bool {
165
165
+
if (a.count == b.count) return std.mem.lessThan(u8, a.name, b.name);
166
166
+
return a.count > b.count;
167
167
+
}
168
168
+
}.lessThan);
169
169
+
summary.collections = try collections.toOwnedSlice(arena);
170
170
+
return summary;
171
171
+
}
172
172
+
173
173
+
fn writeManifest(
174
174
+
io: std.Io,
175
175
+
path: []const u8,
176
176
+
actor: []const u8,
177
177
+
identity: pensieve.pds.Identity,
178
178
+
result: pensieve.repo_walk.WalkResult,
179
179
+
items: []const pensieve.EmbeddableItem,
180
180
+
summary: Summary,
181
181
+
options: pensieve.ExtractOptions,
182
182
+
) !void {
183
183
+
var file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true, .truncate = true });
184
184
+
defer file.close(io);
185
185
+
var buf: [16 * 1024]u8 = undefined;
186
186
+
var writer = std.Io.File.Writer.initStreaming(file, io, &buf);
187
187
+
var jw: std.json.Stringify = .{ .writer = &writer.interface };
188
188
+
try jw.beginObject();
189
189
+
try field(&jw, "actor", actor);
190
190
+
try field(&jw, "did", identity.did);
191
191
+
try field(&jw, "pds", identity.pds);
192
192
+
try jw.objectField("carBytes");
193
193
+
try jw.write(result.car_bytes);
194
194
+
try jw.objectField("records");
195
195
+
try jw.write(result.records.len);
196
196
+
try jw.objectField("items");
197
197
+
try jw.write(items.len);
198
198
+
try jw.objectField("textItems");
199
199
+
try jw.write(summary.text_items);
200
200
+
try jw.objectField("imageRefs");
201
201
+
try jw.write(summary.image_refs);
202
202
+
try jw.objectField("videoRefs");
203
203
+
try jw.write(summary.video_refs);
204
204
+
try jw.objectField("mediaRefsIncluded");
205
205
+
try jw.write(options.include_media_refs);
206
206
+
try jw.objectField("topCollections");
207
207
+
try jw.beginArray();
208
208
+
for (summary.collections[0..@min(summary.collections.len, 20)]) |entry| {
209
209
+
try jw.beginObject();
210
210
+
try field(&jw, "collection", entry.name);
211
211
+
try jw.objectField("count");
212
212
+
try jw.write(entry.count);
213
213
+
try jw.endObject();
214
214
+
}
215
215
+
try jw.endArray();
216
216
+
try jw.endObject();
217
217
+
try writer.interface.writeByte('\n');
218
218
+
try writer.end();
219
219
+
}
220
220
+
221
221
+
fn writeReport(
222
222
+
io: std.Io,
223
223
+
path: []const u8,
224
224
+
actor: []const u8,
225
225
+
identity: pensieve.pds.Identity,
226
226
+
result: pensieve.repo_walk.WalkResult,
227
227
+
items: []const pensieve.EmbeddableItem,
228
228
+
summary: Summary,
229
229
+
corpus_path: []const u8,
230
230
+
manifest_path: []const u8,
231
231
+
options: pensieve.ExtractOptions,
232
232
+
) !void {
233
233
+
var file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true, .truncate = true });
234
234
+
defer file.close(io);
235
235
+
var buf: [64 * 1024]u8 = undefined;
236
236
+
var writer = std.Io.File.Writer.initStreaming(file, io, &buf);
237
237
+
const w = &writer.interface;
238
238
+
239
239
+
try w.writeAll(
240
240
+
\\<!doctype html><html lang="en"><head><meta charset="utf-8">
241
241
+
\\<meta name="viewport" content="width=device-width,initial-scale=1">
242
242
+
\\<title>Pensieve Demo</title>
243
243
+
\\<style>
244
244
+
\\:root{color-scheme:light dark;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
245
245
+
\\body{margin:0;background:#f7f7f4;color:#171717}
246
246
+
\\main{max-width:1120px;margin:0 auto;padding:28px}
247
247
+
\\header{display:grid;gap:10px;padding:22px 0;border-bottom:1px solid #d8d6ce}
248
248
+
\\h1{font-size:34px;line-height:1.05;margin:0;font-weight:760}
249
249
+
\\h2{font-size:18px;margin:30px 0 12px}
250
250
+
\\p{margin:0;color:#4a4a44}
251
251
+
\\.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin:22px 0}
252
252
+
\\.stat,.sample,.bar{background:#fff;border:1px solid #dedbd2;border-radius:8px}
253
253
+
\\.stat{padding:14px}.stat b{display:block;font-size:24px}.stat span{font-size:12px;color:#686861;text-transform:uppercase}
254
254
+
\\.bar{display:grid;grid-template-columns:minmax(160px,260px) 1fr auto;gap:10px;align-items:center;padding:9px 11px;margin:6px 0}
255
255
+
\\.meter{height:9px;background:#e7e3d8;border-radius:99px;overflow:hidden}.meter i{display:block;height:100%;background:#317c6f}
256
256
+
\\.sample{padding:14px;margin:10px 0}.sample code{font-size:12px;color:#555}.sample pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:13px;line-height:1.45;margin:10px 0 0;color:#222}
257
257
+
\\a{color:#17695d} @media (prefers-color-scheme:dark){body{background:#171717;color:#f1efe7}p{color:#beb8aa}.stat,.sample,.bar{background:#20201f;border-color:#383630}.meter{background:#33322d}.sample pre{color:#eee}.sample code{color:#bdb7aa}}
258
258
+
\\</style></head><body><main>
259
259
+
);
260
260
+
261
261
+
try w.writeAll("<header><h1>Pensieve</h1><p>");
262
262
+
try html(w, actor);
263
263
+
try w.writeAll(" -> ");
264
264
+
try html(w, identity.did);
265
265
+
try w.writeAll("</p><p>Raw extracted corpus ready for embedding and backfills.</p></header><section class=\"stats\">");
266
266
+
try stat(w, "CAR bytes", result.car_bytes);
267
267
+
try stat(w, "records", result.records.len);
268
268
+
try stat(w, "items", items.len);
269
269
+
try stat(w, "text", summary.text_items);
270
270
+
try stat(w, "image refs", summary.image_refs);
271
271
+
try stat(w, "video refs", summary.video_refs);
272
272
+
try w.writeAll("</section><p>Wrote <code>");
273
273
+
try html(w, corpus_path);
274
274
+
try w.writeAll("</code> and <code>");
275
275
+
try html(w, manifest_path);
276
276
+
try w.writeAll("</code>. Media refs ");
277
277
+
try w.writeAll(if (options.include_media_refs) "included." else "not included; run with --media to include blob references.");
278
278
+
try w.writeAll("</p><h2>Top Collections</h2>");
279
279
+
280
280
+
const max_count = if (summary.collections.len > 0) summary.collections[0].count else 1;
281
281
+
for (summary.collections[0..@min(summary.collections.len, 18)]) |entry| {
282
282
+
const pct = @max(@as(usize, 1), entry.count * 100 / max_count);
283
283
+
try w.writeAll("<div class=\"bar\"><code>");
284
284
+
try html(w, entry.name);
285
285
+
try w.writeAll("</code><div class=\"meter\"><i style=\"width:");
286
286
+
try w.print("{d}", .{pct});
287
287
+
try w.writeAll("%\"></i></div><b>");
288
288
+
try w.print("{d}", .{entry.count});
289
289
+
try w.writeAll("</b></div>");
290
290
+
}
291
291
+
292
292
+
try w.writeAll("<h2>Sample Items</h2>");
293
293
+
var shown: usize = 0;
294
294
+
for (items) |item| {
295
295
+
if (item.kind != .text or item.text == null) continue;
296
296
+
try w.writeAll("<article class=\"sample\"><code>");
297
297
+
try html(w, item.uri);
298
298
+
try w.writeAll("</code><pre>");
299
299
+
const text = item.text.?;
300
300
+
try html(w, text[0..@min(text.len, 900)]);
301
301
+
if (text.len > 900) try w.writeAll("\n...");
302
302
+
try w.writeAll("</pre></article>");
303
303
+
shown += 1;
304
304
+
if (shown >= 8) break;
305
305
+
}
306
306
+
307
307
+
try w.writeAll("</main></body></html>");
308
308
+
try writer.end();
309
309
+
}
310
310
+
311
311
+
fn stat(w: *std.Io.Writer, label: []const u8, value: usize) !void {
312
312
+
try w.writeAll("<div class=\"stat\"><b>");
313
313
+
try w.print("{d}", .{value});
314
314
+
try w.writeAll("</b><span>");
315
315
+
try html(w, label);
316
316
+
try w.writeAll("</span></div>");
317
317
+
}
318
318
+
319
319
+
fn html(w: *std.Io.Writer, text: []const u8) !void {
320
320
+
for (text) |c| switch (c) {
321
321
+
'&' => try w.writeAll("&"),
322
322
+
'<' => try w.writeAll("<"),
323
323
+
'>' => try w.writeAll(">"),
324
324
+
'"' => try w.writeAll("""),
325
325
+
'\'' => try w.writeAll("'"),
326
326
+
else => try w.writeByte(c),
327
327
+
};
328
328
+
}
329
329
+
330
330
+
fn writeNdjson(writer: *std.Io.Writer, items: []const pensieve.EmbeddableItem) !void {
331
331
+
for (items) |item| {
332
332
+
var jw: std.json.Stringify = .{ .writer = writer };
333
333
+
try jw.beginObject();
334
334
+
try field(&jw, "kind", item.kind.jsonString());
335
335
+
try field(&jw, "uri", item.uri);
336
336
+
try field(&jw, "cid", item.cid);
337
337
+
try field(&jw, "collection", item.collection);
338
338
+
try field(&jw, "authorDid", item.author_did);
339
339
+
if (item.created_at) |created_at| try field(&jw, "createdAt", created_at);
340
340
+
if (item.text) |text| try field(&jw, "text", text);
341
341
+
if (item.blob) |blob| {
342
342
+
try jw.objectField("blob");
343
343
+
try jw.beginObject();
344
344
+
try field(&jw, "cid", blob.cid);
345
345
+
if (blob.mime_type) |mime| try field(&jw, "mimeType", mime);
346
346
+
if (blob.alt) |alt| try field(&jw, "alt", alt);
347
347
+
try jw.endObject();
348
348
+
}
349
349
+
try jw.endObject();
350
350
+
try writer.writeByte('\n');
351
351
+
}
352
352
+
}
353
353
+
354
354
+
fn field(jw: *std.json.Stringify, name: []const u8, value: []const u8) !void {
355
355
+
try jw.objectField(name);
356
356
+
try jw.write(value);
357
357
+
}
358
358
+
359
359
+
fn fail(message: []const u8) !void {
360
360
+
std.debug.print("{s}", .{message});
361
361
+
return error.InvalidArgs;
362
362
+
}
···
1
1
+
//! thin PDS client: handle → DID, DID → PDS URL, describeRepo, listRecords.
2
2
+
//!
3
3
+
//! for v1 all reads go through bsky's public appview (resolveHandle) and
4
4
+
//! plc.directory (DID document). no auth required — we're only reading
5
5
+
//! public data to build an in-memory search index.
6
6
+
7
7
+
const std = @import("std");
8
8
+
const json = std.json;
9
9
+
const Allocator = std.mem.Allocator;
10
10
+
const zat = @import("zat");
11
11
+
12
12
+
const PUBLIC_APPVIEW = "https://public.api.bsky.app";
13
13
+
const PLC_DIRECTORY = "https://plc.directory";
14
14
+
15
15
+
pub const ResolveError = error{
16
16
+
ResolveFailed,
17
17
+
NoPdsEndpoint,
18
18
+
RequestFailed,
19
19
+
OutOfMemory,
20
20
+
ParseFailed,
21
21
+
};
22
22
+
23
23
+
pub const Identity = struct {
24
24
+
did: []const u8,
25
25
+
pds: []const u8,
26
26
+
};
27
27
+
28
28
+
/// resolve a handle or DID to {did, pds_url}. allocates into `arena`.
29
29
+
pub fn resolveIdentity(
30
30
+
arena: Allocator,
31
31
+
transport: *zat.HttpTransport,
32
32
+
actor: []const u8,
33
33
+
) ResolveError!Identity {
34
34
+
// step 1: if `actor` already looks like a DID, use it directly; otherwise
35
35
+
// resolve via bsky's public appview.
36
36
+
const did: []const u8 = if (std.mem.startsWith(u8, actor, "did:"))
37
37
+
try arena.dupe(u8, actor)
38
38
+
else
39
39
+
try resolveHandle(arena, transport, actor);
40
40
+
41
41
+
// step 2: DID document → PDS service endpoint
42
42
+
const pds = try resolvePdsFromDid(arena, transport, did);
43
43
+
return .{ .did = did, .pds = pds };
44
44
+
}
45
45
+
46
46
+
fn resolveHandle(
47
47
+
arena: Allocator,
48
48
+
transport: *zat.HttpTransport,
49
49
+
handle: []const u8,
50
50
+
) ResolveError![]const u8 {
51
51
+
const url = try std.fmt.allocPrint(
52
52
+
arena,
53
53
+
"{s}/xrpc/com.atproto.identity.resolveHandle?handle={s}",
54
54
+
.{ PUBLIC_APPVIEW, handle },
55
55
+
);
56
56
+
57
57
+
const result = transport.fetch(.{ .url = url }) catch return error.RequestFailed;
58
58
+
if (result.status != .ok) return error.ResolveFailed;
59
59
+
60
60
+
const parsed = json.parseFromSliceLeaky(json.Value, arena, result.body, .{}) catch
61
61
+
return error.ParseFailed;
62
62
+
63
63
+
const did_val = parsed.object.get("did") orelse return error.ResolveFailed;
64
64
+
if (did_val != .string) return error.ResolveFailed;
65
65
+
return try arena.dupe(u8, did_val.string);
66
66
+
}
67
67
+
68
68
+
fn resolvePdsFromDid(
69
69
+
arena: Allocator,
70
70
+
transport: *zat.HttpTransport,
71
71
+
did: []const u8,
72
72
+
) ResolveError![]const u8 {
73
73
+
const url = if (std.mem.startsWith(u8, did, "did:plc:"))
74
74
+
try std.fmt.allocPrint(arena, "{s}/{s}", .{ PLC_DIRECTORY, did })
75
75
+
else if (std.mem.startsWith(u8, did, "did:web:")) blk: {
76
76
+
const domain = did["did:web:".len..];
77
77
+
break :blk try std.fmt.allocPrint(arena, "https://{s}/.well-known/did.json", .{domain});
78
78
+
} else return error.ResolveFailed;
79
79
+
80
80
+
const result = transport.fetch(.{ .url = url }) catch return error.RequestFailed;
81
81
+
if (result.status != .ok) return error.ResolveFailed;
82
82
+
83
83
+
const parsed = json.parseFromSliceLeaky(json.Value, arena, result.body, .{}) catch
84
84
+
return error.ParseFailed;
85
85
+
86
86
+
const services = parsed.object.get("service") orelse return error.NoPdsEndpoint;
87
87
+
if (services != .array) return error.NoPdsEndpoint;
88
88
+
89
89
+
for (services.array.items) |svc| {
90
90
+
if (svc != .object) continue;
91
91
+
const svc_type = svc.object.get("type") orelse continue;
92
92
+
if (svc_type != .string) continue;
93
93
+
if (!std.mem.eql(u8, svc_type.string, "AtprotoPersonalDataServer")) continue;
94
94
+
const endpoint = svc.object.get("serviceEndpoint") orelse continue;
95
95
+
if (endpoint != .string) continue;
96
96
+
return try arena.dupe(u8, endpoint.string);
97
97
+
}
98
98
+
return error.NoPdsEndpoint;
99
99
+
}
100
100
+
101
101
+
/// get the list of collection NSIDs present in a repo.
102
102
+
pub fn describeRepo(
103
103
+
arena: Allocator,
104
104
+
transport: *zat.HttpTransport,
105
105
+
pds: []const u8,
106
106
+
did: []const u8,
107
107
+
) ResolveError![]const []const u8 {
108
108
+
const url = try std.fmt.allocPrint(
109
109
+
arena,
110
110
+
"{s}/xrpc/com.atproto.repo.describeRepo?repo={s}",
111
111
+
.{ pds, did },
112
112
+
);
113
113
+
114
114
+
const result = transport.fetch(.{ .url = url }) catch return error.RequestFailed;
115
115
+
if (result.status != .ok) return error.ResolveFailed;
116
116
+
117
117
+
const parsed = json.parseFromSliceLeaky(json.Value, arena, result.body, .{}) catch
118
118
+
return error.ParseFailed;
119
119
+
120
120
+
const collections = parsed.object.get("collections") orelse return error.ParseFailed;
121
121
+
if (collections != .array) return error.ParseFailed;
122
122
+
123
123
+
var out: std.ArrayList([]const u8) = .empty;
124
124
+
for (collections.array.items) |c| {
125
125
+
if (c != .string) continue;
126
126
+
try out.append(arena, try arena.dupe(u8, c.string));
127
127
+
}
128
128
+
return out.items;
129
129
+
}
130
130
+
131
131
+
pub const Record = struct {
132
132
+
uri: []const u8,
133
133
+
cid: []const u8,
134
134
+
value: json.Value, // arena-backed, walked by record_text.zig
135
135
+
};
136
136
+
137
137
+
pub const ListPage = struct {
138
138
+
records: []Record,
139
139
+
cursor: ?[]const u8,
140
140
+
};
141
141
+
142
142
+
/// one page of listRecords for a given collection. caller loops until cursor
143
143
+
/// is null to get everything. `limit` max is 100 per the spec.
144
144
+
pub fn listRecords(
145
145
+
arena: Allocator,
146
146
+
transport: *zat.HttpTransport,
147
147
+
pds: []const u8,
148
148
+
did: []const u8,
149
149
+
collection: []const u8,
150
150
+
cursor: ?[]const u8,
151
151
+
limit: u32,
152
152
+
) ResolveError!ListPage {
153
153
+
const url = if (cursor) |c|
154
154
+
try std.fmt.allocPrint(
155
155
+
arena,
156
156
+
"{s}/xrpc/com.atproto.repo.listRecords?repo={s}&collection={s}&limit={d}&cursor={s}",
157
157
+
.{ pds, did, collection, limit, c },
158
158
+
)
159
159
+
else
160
160
+
try std.fmt.allocPrint(
161
161
+
arena,
162
162
+
"{s}/xrpc/com.atproto.repo.listRecords?repo={s}&collection={s}&limit={d}",
163
163
+
.{ pds, did, collection, limit },
164
164
+
);
165
165
+
166
166
+
const result = transport.fetch(.{ .url = url }) catch return error.RequestFailed;
167
167
+
if (result.status != .ok) return error.ResolveFailed;
168
168
+
169
169
+
const parsed = json.parseFromSliceLeaky(json.Value, arena, result.body, .{}) catch
170
170
+
return error.ParseFailed;
171
171
+
172
172
+
var out_records: std.ArrayList(Record) = .empty;
173
173
+
174
174
+
if (parsed.object.get("records")) |records_val| {
175
175
+
if (records_val == .array) {
176
176
+
for (records_val.array.items) |r| {
177
177
+
if (r != .object) continue;
178
178
+
const uri = (r.object.get("uri") orelse continue);
179
179
+
const cid = (r.object.get("cid") orelse continue);
180
180
+
const value = r.object.get("value") orelse continue;
181
181
+
if (uri != .string or cid != .string) continue;
182
182
+
try out_records.append(arena, .{
183
183
+
.uri = try arena.dupe(u8, uri.string),
184
184
+
.cid = try arena.dupe(u8, cid.string),
185
185
+
.value = value,
186
186
+
});
187
187
+
}
188
188
+
}
189
189
+
}
190
190
+
191
191
+
const cursor_out: ?[]const u8 = blk: {
192
192
+
const v = parsed.object.get("cursor") orelse break :blk null;
193
193
+
if (v != .string) break :blk null;
194
194
+
if (v.string.len == 0) break :blk null;
195
195
+
break :blk try arena.dupe(u8, v.string);
196
196
+
};
197
197
+
198
198
+
return .{ .records = out_records.items, .cursor = cursor_out };
199
199
+
}
200
200
+
201
201
+
/// fetch a single record by collection + rkey. unauthenticated. returns the
202
202
+
/// record value (json.Value) or null if the record doesn't exist (404/400).
203
203
+
pub fn getRecord(
204
204
+
arena: Allocator,
205
205
+
transport: *zat.HttpTransport,
206
206
+
pds: []const u8,
207
207
+
did: []const u8,
208
208
+
collection: []const u8,
209
209
+
rkey: []const u8,
210
210
+
) ?json.Value {
211
211
+
const url = std.fmt.allocPrint(
212
212
+
arena,
213
213
+
"{s}/xrpc/com.atproto.repo.getRecord?repo={s}&collection={s}&rkey={s}",
214
214
+
.{ pds, did, collection, rkey },
215
215
+
) catch return null;
216
216
+
217
217
+
const result = transport.fetch(.{ .url = url }) catch return null;
218
218
+
if (result.status != .ok) return null;
219
219
+
220
220
+
const parsed = json.parseFromSliceLeaky(json.Value, arena, result.body, .{}) catch
221
221
+
return null;
222
222
+
223
223
+
return parsed.object.get("value");
224
224
+
}
225
225
+
226
226
+
/// fetch a blob by cid via com.atproto.sync.getBlob. unauthenticated — blobs
227
227
+
/// in a public PDS are readable by anyone. returns the raw bytes owned by
228
228
+
/// `arena`.
229
229
+
pub fn getBlob(
230
230
+
arena: Allocator,
231
231
+
transport: *zat.HttpTransport,
232
232
+
pds: []const u8,
233
233
+
did: []const u8,
234
234
+
cid: []const u8,
235
235
+
) ResolveError![]const u8 {
236
236
+
const url = try std.fmt.allocPrint(
237
237
+
arena,
238
238
+
"{s}/xrpc/com.atproto.sync.getBlob?did={s}&cid={s}",
239
239
+
.{ pds, did, cid },
240
240
+
);
241
241
+
const result = transport.fetch(.{ .url = url }) catch return error.RequestFailed;
242
242
+
if (result.status != .ok) return error.ResolveFailed;
243
243
+
// result.body is transport-owned; dup into the caller's arena so it
244
244
+
// outlives this call.
245
245
+
return try arena.dupe(u8, result.body);
246
246
+
}
···
1
1
+
//! record_to_text heuristic — port of embedder/build_pack.py.
2
2
+
//!
3
3
+
//! walks arbitrary atproto record JSON and produces embedding-ready text.
4
4
+
//! rules:
5
5
+
//! - prepend `collection: <nsid>` so the lex name is in the vector
6
6
+
//! - drop atproto plumbing keys ($type, cid, rev, sig, prev, version, $link)
7
7
+
//! - drop identifier-shaped strings (DIDs, at-uris, CIDs, TID rkeys, hex hashes)
8
8
+
//! - convert iso timestamps to year only (keeps temporal signal, not noise)
9
9
+
//! - render as flat `key.path: value` lines
10
10
+
//! - strongRefs and DIDs are currently dropped (v2: deref via slingshot)
11
11
+
//! - truncate final text at 4000 chars
12
12
+
13
13
+
const std = @import("std");
14
14
+
const json = std.json;
15
15
+
const Allocator = std.mem.Allocator;
16
16
+
17
17
+
const MAX_TEXT_LEN = 4000;
18
18
+
19
19
+
const NOISE_KEYS = [_][]const u8{
20
20
+
"$type", "cid", "rev", "sig", "prev", "version", "$link",
21
21
+
};
22
22
+
23
23
+
fn isNoiseKey(k: []const u8) bool {
24
24
+
if (k.len == 0) return false;
25
25
+
if (k[0] == '$') return true;
26
26
+
for (NOISE_KEYS) |n| if (std.mem.eql(u8, k, n)) return true;
27
27
+
return false;
28
28
+
}
29
29
+
30
30
+
fn isIdentifier(s: []const u8) bool {
31
31
+
if (s.len == 0) return false;
32
32
+
if (std.mem.startsWith(u8, s, "did:plc:")) return true;
33
33
+
if (std.mem.startsWith(u8, s, "did:web:")) return true;
34
34
+
if (std.mem.startsWith(u8, s, "at://")) return true;
35
35
+
// CID v1 raw/dag-cbor
36
36
+
if (std.mem.startsWith(u8, s, "bafy")) return true;
37
37
+
if (std.mem.startsWith(u8, s, "bafk")) return true;
38
38
+
// TID format: exactly 13 chars, lowercase base32 subset [2-7a-z]
39
39
+
if (s.len == 13) {
40
40
+
var all_tid = true;
41
41
+
for (s) |ch| {
42
42
+
const ok = (ch >= 'a' and ch <= 'z') or (ch >= '2' and ch <= '7');
43
43
+
if (!ok) {
44
44
+
all_tid = false;
45
45
+
break;
46
46
+
}
47
47
+
}
48
48
+
if (all_tid) return true;
49
49
+
}
50
50
+
// hex hash of 32+ chars
51
51
+
if (s.len >= 32) {
52
52
+
var all_hex = true;
53
53
+
for (s) |ch| {
54
54
+
const ok = (ch >= '0' and ch <= '9') or (ch >= 'a' and ch <= 'f');
55
55
+
if (!ok) {
56
56
+
all_hex = false;
57
57
+
break;
58
58
+
}
59
59
+
}
60
60
+
if (all_hex) return true;
61
61
+
}
62
62
+
return false;
63
63
+
}
64
64
+
65
65
+
fn looksLikeIsoTimestamp(s: []const u8) bool {
66
66
+
// YYYY-MM-DDT... pattern (loose)
67
67
+
if (s.len < 11) return false;
68
68
+
for (0..4) |i| if (!std.ascii.isDigit(s[i])) return false;
69
69
+
if (s[4] != '-') return false;
70
70
+
for (5..7) |i| if (!std.ascii.isDigit(s[i])) return false;
71
71
+
if (s[7] != '-') return false;
72
72
+
for (8..10) |i| if (!std.ascii.isDigit(s[i])) return false;
73
73
+
return s[10] == 'T' or s[10] == ' ';
74
74
+
}
75
75
+
76
76
+
/// shape check: is this a strongRef {uri: "at://...", cid: "bafy..."}?
77
77
+
fn isStrongRef(v: json.Value) bool {
78
78
+
if (v != .object) return false;
79
79
+
const uri_v = v.object.get("uri") orelse return false;
80
80
+
const cid_v = v.object.get("cid") orelse return false;
81
81
+
if (uri_v != .string or cid_v != .string) return false;
82
82
+
return std.mem.startsWith(u8, uri_v.string, "at://");
83
83
+
}
84
84
+
85
85
+
pub const Builder = struct {
86
86
+
buf: std.ArrayList(u8),
87
87
+
allocator: Allocator,
88
88
+
89
89
+
pub fn init(allocator: Allocator) Builder {
90
90
+
return .{ .buf = .empty, .allocator = allocator };
91
91
+
}
92
92
+
93
93
+
fn appendLine(self: *Builder, path: []const u8, value: []const u8) !void {
94
94
+
if (self.buf.items.len >= MAX_TEXT_LEN) return;
95
95
+
if (self.buf.items.len > 0) try self.buf.append(self.allocator, '\n');
96
96
+
if (path.len > 0) {
97
97
+
try self.buf.appendSlice(self.allocator, path);
98
98
+
try self.buf.appendSlice(self.allocator, ": ");
99
99
+
}
100
100
+
try self.buf.appendSlice(self.allocator, value);
101
101
+
}
102
102
+
};
103
103
+
104
104
+
/// walk a parsed record value, appending flattened text to `builder`.
105
105
+
fn walk(builder: *Builder, node: json.Value, path: []const u8) anyerror!void {
106
106
+
if (builder.buf.items.len >= MAX_TEXT_LEN) return;
107
107
+
108
108
+
// strongRef short-circuit: drop (v2: inline dereffed content)
109
109
+
if (isStrongRef(node)) return;
110
110
+
111
111
+
switch (node) {
112
112
+
.object => |obj| {
113
113
+
var it = obj.iterator();
114
114
+
while (it.next()) |entry| {
115
115
+
const k = entry.key_ptr.*;
116
116
+
if (isNoiseKey(k)) continue;
117
117
+
118
118
+
const next_path = if (path.len == 0)
119
119
+
try builder.allocator.dupe(u8, k)
120
120
+
else
121
121
+
try std.fmt.allocPrint(builder.allocator, "{s}.{s}", .{ path, k });
122
122
+
123
123
+
try walk(builder, entry.value_ptr.*, next_path);
124
124
+
}
125
125
+
},
126
126
+
.array => |arr| {
127
127
+
// pure string arrays → comma-joined
128
128
+
var all_strings = true;
129
129
+
for (arr.items) |item| {
130
130
+
if (item != .string) {
131
131
+
all_strings = false;
132
132
+
break;
133
133
+
}
134
134
+
}
135
135
+
if (all_strings) {
136
136
+
var kept: std.ArrayList([]const u8) = .empty;
137
137
+
for (arr.items) |item| {
138
138
+
if (!isIdentifier(item.string)) {
139
139
+
try kept.append(builder.allocator, item.string);
140
140
+
}
141
141
+
}
142
142
+
if (kept.items.len > 0) {
143
143
+
const joined = try std.mem.join(builder.allocator, ", ", kept.items);
144
144
+
try builder.appendLine(path, joined);
145
145
+
}
146
146
+
} else {
147
147
+
for (arr.items) |item| try walk(builder, item, path);
148
148
+
}
149
149
+
},
150
150
+
.string => |s| {
151
151
+
// DID reference → drop (v2: inline profile)
152
152
+
if (std.mem.startsWith(u8, s, "did:plc:") or std.mem.startsWith(u8, s, "did:web:")) return;
153
153
+
if (isIdentifier(s)) return;
154
154
+
if (looksLikeIsoTimestamp(s)) {
155
155
+
try builder.appendLine(path, s[0..4]); // keep year only
156
156
+
return;
157
157
+
}
158
158
+
try builder.appendLine(path, s);
159
159
+
},
160
160
+
.integer => |i| {
161
161
+
if (i == 0) return;
162
162
+
const s = try std.fmt.allocPrint(builder.allocator, "{d}", .{i});
163
163
+
try builder.appendLine(path, s);
164
164
+
},
165
165
+
.float => |f| {
166
166
+
if (f == 0.0) return;
167
167
+
const s = try std.fmt.allocPrint(builder.allocator, "{d}", .{f});
168
168
+
try builder.appendLine(path, s);
169
169
+
},
170
170
+
.bool => return, // rarely meaningful for retrieval
171
171
+
.null => return,
172
172
+
.number_string => |s| try builder.appendLine(path, s),
173
173
+
}
174
174
+
}
175
175
+
176
176
+
/// produce embedding-ready text for a record. result is allocated in `arena`
177
177
+
/// and truncated at MAX_TEXT_LEN.
178
178
+
pub fn recordToText(
179
179
+
arena: Allocator,
180
180
+
collection: []const u8,
181
181
+
value: json.Value,
182
182
+
) ![]const u8 {
183
183
+
var b = Builder.init(arena);
184
184
+
// prepend the NSID — strongest structural signal
185
185
+
try b.appendLine("collection", collection);
186
186
+
try walk(&b, value, "");
187
187
+
const len = @min(b.buf.items.len, MAX_TEXT_LEN);
188
188
+
return b.buf.items[0..len];
189
189
+
}
···
1
1
+
//! single-request repo walk via `com.atproto.sync.getRepo`.
2
2
+
//!
3
3
+
//! the previous walker used paginated `listRecords` on every collection —
4
4
+
//! ~172 HTTP round trips for a 17k-record repo. this does one request,
5
5
+
//! pulls the entire repo as a CAR file (DAG-CBOR blocks + MST structure),
6
6
+
//! and yields records by walking the MST locally.
7
7
+
//!
8
8
+
//! three-stage, streaming-friendly fetch + parse:
9
9
+
//! 1. HTTP GET /xrpc/com.atproto.sync.getRepo, draining the response body
10
10
+
//! directly into a temp file in /tmp. no intermediate heap buffer, so
11
11
+
//! peak RSS is bounded regardless of repo size.
12
12
+
//! 2. mmap the temp file read-only and feed the slice to
13
13
+
//! `zat.car.streamBlocks`, building a `CID → byte range` index.
14
14
+
//! 3. MST walk starts at the commit root, looks up each child CID by
15
15
+
//! range, decodes the MST/record block on demand, and yields records.
16
16
+
//!
17
17
+
//! the mmap + the index are the only things resident beyond the arena's
18
18
+
//! per-record decode churn. kernel handles paging for the CAR bytes. temp
19
19
+
//! file is deleted before walkRepo returns (success or failure).
20
20
+
//!
21
21
+
//! this unblocks very large repos (pfrazee.com sits at ~248k blocks / 72 MB,
22
22
+
//! well past the heap-based approach on a 4 GB fly machine running two
23
23
+
//! concurrent indexes).
24
24
+
//!
25
25
+
//! we talk to std.http.Client directly for this one call instead of going
26
26
+
//! through zat.HttpTransport.fetch — the latter always buffers the whole
27
27
+
//! body via std.Io.Writer.Allocating, and for multi-hundred-MB repos that
28
28
+
//! defeats the point. every other ken call still uses the zat helper.
29
29
+
//!
30
30
+
//! relies on zat for the protocol primitives:
31
31
+
//! - zat.car.streamBlocks for CAR parsing (zero-alloc iterator)
32
32
+
//! - zat.mst.decodeMstNode for MST node decoding
33
33
+
//! - zat.cbor.decodeAll for per-block DAG-CBOR decoding
34
34
+
//! - zat.multibase.base32lower.encode for CID → "bafy..." stringification
35
35
+
//!
36
36
+
//! records come out with the same (uri, cid, value) shape as pds.listRecords
37
37
+
//! so indexer.doIndex can swap the two walks behind a try-CAR-fallback-to-
38
38
+
//! listRecords flow.
39
39
+
40
40
+
const std = @import("std");
41
41
+
const Io = std.Io;
42
42
+
const Allocator = std.mem.Allocator;
43
43
+
const json = std.json;
44
44
+
const zat = @import("zat");
45
45
+
46
46
+
pub const Record = struct {
47
47
+
/// at://{did}/{collection}/{rkey}
48
48
+
uri: []const u8,
49
49
+
/// base32 multibase CID string ("bafy...")
50
50
+
cid: []const u8,
51
51
+
/// NSID, derived from the MST key's collection prefix
52
52
+
collection: []const u8,
53
53
+
/// parsed record body, converted from DAG-CBOR to std.json.Value so it
54
54
+
/// plugs into the existing record_text / display pipelines
55
55
+
value: json.Value,
56
56
+
};
57
57
+
58
58
+
pub const WalkResult = struct {
59
59
+
records: []Record,
60
60
+
/// total CAR size in bytes (for logging / diagnostics)
61
61
+
car_bytes: usize,
62
62
+
skipped_by_collection: usize,
63
63
+
skipped_by_time: usize,
64
64
+
};
65
65
+
66
66
+
/// skip predicate applied per-record during the MST walk. filtering happens
67
67
+
/// before value decode/dupe, so skipped records cost roughly the MST entry
68
68
+
/// iteration and nothing else.
69
69
+
pub const WalkFilter = struct {
70
70
+
/// exact NSID matches to drop (e.g. "app.bsky.feed.like"). comparison
71
71
+
/// is O(n*m) on entry count × skip list size — both are small in
72
72
+
/// practice, not worth hashing.
73
73
+
skip_collections: []const []const u8 = &.{},
74
74
+
/// drop records whose rkey decodes to a TID timestamp older than this
75
75
+
/// cutoff (unix microseconds). records with non-TID rkeys (e.g.
76
76
+
/// `self`, profile) are always kept — time cutoff is a best-effort
77
77
+
/// filter, not a hard wall.
78
78
+
min_rkey_tid_us: ?i64 = null,
79
79
+
};
80
80
+
81
81
+
pub const CountResult = struct {
82
82
+
/// records that would be kept under the filter
83
83
+
kept: usize,
84
84
+
skipped_by_collection: usize,
85
85
+
skipped_by_time: usize,
86
86
+
};
87
87
+
88
88
+
pub const WalkError = error{
89
89
+
FetchFailed,
90
90
+
EmptyCar,
91
91
+
NoCommitBlock,
92
92
+
InvalidCommit,
93
93
+
NoDataField,
94
94
+
InvalidDataField,
95
95
+
BlockNotFound,
96
96
+
InvalidMstNode,
97
97
+
InvalidRecordCbor,
98
98
+
OutOfMemory,
99
99
+
};
100
100
+
101
101
+
/// entry in the CID → byte-range index. `off` / `len` point into the CAR
102
102
+
/// response buffer. 16 bytes per block instead of 48+ in a StringHashMap
103
103
+
/// of slices.
104
104
+
const BlockRange = packed struct {
105
105
+
off: u64,
106
106
+
len: u32,
107
107
+
_pad: u32 = 0,
108
108
+
};
109
109
+
110
110
+
/// CID bytes → byte range inside `car_bytes`. keys borrow from the CAR
111
111
+
/// buffer (zat's BlockIterator yields zero-copy cid slices), so this is
112
112
+
/// ~16 bytes of value + a few bytes of hashmap overhead per block.
113
113
+
const BlockIndex = std.StringHashMapUnmanaged(BlockRange);
114
114
+
115
115
+
/// process-local counter used to generate unique temp-file names for
116
116
+
/// concurrent `fetchRepoToTempFile` calls.
117
117
+
var fetch_seq: std.atomic.Value(u64) = .init(0);
118
118
+
119
119
+
/// A repo that has been fetched + mmap'd and had its block index built.
120
120
+
/// Holds resources (temp file, mmap) that must be released via `close`.
121
121
+
/// Callers typically do `openRepo` → zero or more `walkOpened`/`countOpened`
122
122
+
/// passes → `close`. The two-pass pattern (count → decide filter → walk)
123
123
+
/// avoids re-fetching the CAR.
124
124
+
pub const OpenedRepo = struct {
125
125
+
fetched: FetchedCar,
126
126
+
car_bytes: []const u8,
127
127
+
index: BlockIndex,
128
128
+
/// raw bytes of the MST root CID (entry point for walks). borrows
129
129
+
/// from the arena the repo was opened with.
130
130
+
data_cid: []const u8,
131
131
+
132
132
+
pub fn close(self: *OpenedRepo, io: Io) void {
133
133
+
self.fetched.destroy(io);
134
134
+
}
135
135
+
};
136
136
+
137
137
+
/// Fetch the repo CAR, stream-index its blocks, and locate the MST root.
138
138
+
/// Returns an `OpenedRepo` that can be walked (or counted) any number of
139
139
+
/// times without re-fetching. Caller must `close` when done.
140
140
+
pub fn openRepo(
141
141
+
arena: Allocator,
142
142
+
io: Io,
143
143
+
pds_url: []const u8,
144
144
+
did: []const u8,
145
145
+
) WalkError!OpenedRepo {
146
146
+
// 1. stream the HTTP response body straight into a temp file in /tmp,
147
147
+
// then mmap it read-only. peak RSS stays bounded no matter how big
148
148
+
// the repo is (kernel pages in what we touch, evicts what we don't).
149
149
+
var fetched = fetchRepoToTempFile(arena, io, pds_url, did) catch |err| switch (err) {
150
150
+
error.OutOfMemory => return error.OutOfMemory,
151
151
+
else => return error.FetchFailed,
152
152
+
};
153
153
+
errdefer fetched.destroy(io);
154
154
+
155
155
+
const car_bytes: []const u8 = fetched.bytes;
156
156
+
if (car_bytes.len == 0) return error.EmptyCar;
157
157
+
158
158
+
// 2. stream the CAR once to extract roots + build a CID → byte-range
159
159
+
// index. passing max_size = car_bytes.len disables zat's 2 MB guard
160
160
+
// for trusted input we fetched ourselves. no max_blocks cap — the
161
161
+
// iterator is O(1) space per block, so block count is bounded only
162
162
+
// by CAR size (max_block_size still protects us from malformed
163
163
+
// over-long blocks).
164
164
+
var stream = zat.car.streamBlocks(arena, car_bytes, .{
165
165
+
.max_size = car_bytes.len,
166
166
+
}) catch return error.EmptyCar;
167
167
+
if (stream.roots.len == 0) return error.EmptyCar;
168
168
+
169
169
+
var index: BlockIndex = .empty;
170
170
+
// reserve in proportion to the CAR size — avg block is ~300 bytes in
171
171
+
// practice, so car_bytes.len / 256 is a decent upper-bound estimate
172
172
+
// that keeps us in a single rehash on all but the densest repos.
173
173
+
try index.ensureTotalCapacity(arena, @intCast(@min(car_bytes.len / 256, std.math.maxInt(u32))));
174
174
+
175
175
+
while (true) {
176
176
+
const block = stream.iter.next() catch |err| switch (err) {
177
177
+
error.OutOfMemory => return error.OutOfMemory,
178
178
+
else => return error.EmptyCar,
179
179
+
} orelse break;
180
180
+
// block.data is a zero-copy slice into car_bytes — recover its
181
181
+
// byte range directly from pointer arithmetic, no extra state.
182
182
+
const data_off: u64 = @intCast(@intFromPtr(block.data.ptr) - @intFromPtr(car_bytes.ptr));
183
183
+
try index.put(arena, block.cid_raw, .{
184
184
+
.off = data_off,
185
185
+
.len = @intCast(block.data.len),
186
186
+
});
187
187
+
}
188
188
+
189
189
+
// 3. commit block → data CID (MST root)
190
190
+
const commit_cid_raw = stream.roots[0].raw;
191
191
+
const commit_data = indexLookup(car_bytes, index, commit_cid_raw) orelse return error.NoCommitBlock;
192
192
+
const commit_value = zat.cbor.decodeAll(arena, commit_data) catch return error.InvalidCommit;
193
193
+
const data_cbor = commit_value.get("data") orelse return error.NoDataField;
194
194
+
const data_cid_raw = switch (data_cbor) {
195
195
+
.cid => |c| c.raw,
196
196
+
else => return error.InvalidDataField,
197
197
+
};
198
198
+
199
199
+
return .{
200
200
+
.fetched = fetched,
201
201
+
.car_bytes = car_bytes,
202
202
+
.index = index,
203
203
+
.data_cid = data_cid_raw,
204
204
+
};
205
205
+
}
206
206
+
207
207
+
/// MST walk that yields records through `arena`. Uses `filter` to drop
208
208
+
/// records before value decode — skipped records cost only the MST entry
209
209
+
/// iteration, not a CBOR roundtrip.
210
210
+
pub fn walkOpened(
211
211
+
arena: Allocator,
212
212
+
opened: *const OpenedRepo,
213
213
+
did: []const u8,
214
214
+
filter: WalkFilter,
215
215
+
) WalkError!WalkResult {
216
216
+
var records: std.ArrayList(Record) = .empty;
217
217
+
var ctx: WalkCtx = .{
218
218
+
.arena = arena,
219
219
+
.car_bytes = opened.car_bytes,
220
220
+
.index = opened.index,
221
221
+
.did = did,
222
222
+
.filter = filter,
223
223
+
.out = &records,
224
224
+
};
225
225
+
try walkNode(&ctx, opened.data_cid);
226
226
+
227
227
+
return .{
228
228
+
.records = try records.toOwnedSlice(arena),
229
229
+
.car_bytes = opened.car_bytes.len,
230
230
+
.skipped_by_collection = ctx.skipped_by_collection,
231
231
+
.skipped_by_time = ctx.skipped_by_time,
232
232
+
};
233
233
+
}
234
234
+
235
235
+
/// Count-only MST walk. Applies the filter and counts kept records + skip
236
236
+
/// buckets, but does not decode record values — cheap enough to run before
237
237
+
/// the real walk as a "how many records would we embed?" probe.
238
238
+
pub fn countOpened(
239
239
+
arena: Allocator,
240
240
+
opened: *const OpenedRepo,
241
241
+
filter: WalkFilter,
242
242
+
) WalkError!CountResult {
243
243
+
var ctx: WalkCtx = .{
244
244
+
.arena = arena,
245
245
+
.car_bytes = opened.car_bytes,
246
246
+
.index = opened.index,
247
247
+
.did = "",
248
248
+
.filter = filter,
249
249
+
.out = null,
250
250
+
};
251
251
+
try walkNode(&ctx, opened.data_cid);
252
252
+
return .{
253
253
+
.kept = ctx.kept,
254
254
+
.skipped_by_collection = ctx.skipped_by_collection,
255
255
+
.skipped_by_time = ctx.skipped_by_time,
256
256
+
};
257
257
+
}
258
258
+
259
259
+
/// Convenience: open → walk → close in one call. Equivalent to the
260
260
+
/// pre-filter walkRepo API; used by the smoke test and any caller that
261
261
+
/// doesn't need the count-then-walk staging.
262
262
+
pub fn walkRepo(
263
263
+
arena: Allocator,
264
264
+
io: Io,
265
265
+
pds_url: []const u8,
266
266
+
did: []const u8,
267
267
+
filter: WalkFilter,
268
268
+
) WalkError!WalkResult {
269
269
+
var opened = try openRepo(arena, io, pds_url, did);
270
270
+
defer opened.close(io);
271
271
+
return walkOpened(arena, &opened, did, filter);
272
272
+
}
273
273
+
274
274
+
/// owns a temp file + its mmap. deletes the temp file on destroy.
275
275
+
const FetchedCar = struct {
276
276
+
/// absolute path in /tmp, so we can unlink on destroy
277
277
+
path: []const u8,
278
278
+
file: std.Io.File,
279
279
+
mmap: std.Io.File.MemoryMap,
280
280
+
bytes: []const u8,
281
281
+
282
282
+
fn destroy(self: *FetchedCar, io: Io) void {
283
283
+
self.mmap.destroy(io);
284
284
+
self.file.close(io);
285
285
+
// best-effort delete; leaking a /tmp file on shutdown is fine
286
286
+
std.Io.Dir.deleteFileAbsolute(io, self.path) catch {};
287
287
+
}
288
288
+
};
289
289
+
290
290
+
/// GET /xrpc/com.atproto.sync.getRepo and drain the response body directly
291
291
+
/// into a temp file, then mmap the file read-only. never holds the full
292
292
+
/// CAR in heap. `path` is owned by `arena`.
293
293
+
fn fetchRepoToTempFile(
294
294
+
arena: Allocator,
295
295
+
io: Io,
296
296
+
pds_url: []const u8,
297
297
+
did: []const u8,
298
298
+
) !FetchedCar {
299
299
+
const url = try std.fmt.allocPrint(arena, "{s}/xrpc/com.atproto.sync.getRepo?did={s}", .{ pds_url, did });
300
300
+
301
301
+
// path: /tmp/ken-car-{seq}-{did-tail}.car — a process-local monotonic
302
302
+
// counter is enough to disambiguate concurrent indexes (ken is a
303
303
+
// single-process server); did-tail is purely for human debugging
304
304
+
// when poking at /tmp by hand.
305
305
+
const did_tail_start = if (std.mem.lastIndexOfScalar(u8, did, ':')) |i| i + 1 else 0;
306
306
+
const did_tail = did[did_tail_start..@min(did_tail_start + 16, did.len)];
307
307
+
const seq = fetch_seq.fetchAdd(1, .monotonic);
308
308
+
const path = try std.fmt.allocPrint(arena, "/tmp/ken-car-{d}-{s}.car", .{ seq, did_tail });
309
309
+
310
310
+
// --- create the temp file + wrap it in a buffered writer ---
311
311
+
var out_file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true });
312
312
+
errdefer {
313
313
+
out_file.close(io);
314
314
+
std.Io.Dir.deleteFileAbsolute(io, path) catch {};
315
315
+
}
316
316
+
317
317
+
var write_buf: [64 * 1024]u8 = undefined;
318
318
+
var out_writer = std.Io.File.Writer.initStreaming(out_file, io, &write_buf);
319
319
+
320
320
+
// --- issue the HTTP request ---
321
321
+
var client: std.http.Client = .{ .allocator = arena, .io = io };
322
322
+
defer client.deinit();
323
323
+
324
324
+
var req = try client.request(.GET, try std.Uri.parse(url), .{
325
325
+
// see notes in oauth.zig:pdsAuthedRequest — std.http.Client low-level
326
326
+
// path doesn't transparently decompress, so force identity encoding
327
327
+
// via the typed header slot (extra_headers is additive and produces
328
328
+
// conflicting Accept-Encoding values).
329
329
+
.headers = .{ .accept_encoding = .{ .override = "identity" } },
330
330
+
});
331
331
+
defer req.deinit();
332
332
+
try req.sendBodiless();
333
333
+
334
334
+
// 2 KB is enough for any reasonable redirect target — bsky.network's
335
335
+
// sync.getRepo redirects to the user's actual PDS hostname, which is
336
336
+
// at most a couple hundred bytes; matching oauth.zig's 4096-byte
337
337
+
// auth_hdr_buf is overkill but cheap and future-proof.
338
338
+
var redirect_buf: [2048]u8 = undefined;
339
339
+
var response = try req.receiveHead(&redirect_buf);
340
340
+
if (response.head.status != .ok) return error.FetchFailed;
341
341
+
342
342
+
// --- drain body into the file ---
343
343
+
const reader = response.reader(&.{});
344
344
+
_ = try reader.streamRemaining(&out_writer.interface);
345
345
+
try out_writer.end();
346
346
+
347
347
+
const file_len = try out_file.length(io);
348
348
+
if (file_len == 0) {
349
349
+
out_file.close(io);
350
350
+
std.Io.Dir.deleteFileAbsolute(io, path) catch {};
351
351
+
return error.EmptyCar;
352
352
+
}
353
353
+
354
354
+
// --- mmap the file read-only ---
355
355
+
var mmap = try std.Io.File.MemoryMap.create(io, out_file, .{
356
356
+
.len = @intCast(file_len),
357
357
+
.protection = .{ .read = true, .write = false },
358
358
+
.offset = 0,
359
359
+
});
360
360
+
errdefer mmap.destroy(io);
361
361
+
362
362
+
return .{
363
363
+
.path = path,
364
364
+
.file = out_file,
365
365
+
.mmap = mmap,
366
366
+
.bytes = mmap.memory[0..@intCast(file_len)],
367
367
+
};
368
368
+
}
369
369
+
370
370
+
fn indexLookup(car_bytes: []const u8, index: BlockIndex, cid: []const u8) ?[]const u8 {
371
371
+
const range = index.get(cid) orelse return null;
372
372
+
return car_bytes[range.off..][0..range.len];
373
373
+
}
374
374
+
375
375
+
/// shared state for recursive MST traversal. `out == null` means
376
376
+
/// count-only mode: apply filter, track stats, but skip CBOR value decode
377
377
+
/// and record materialization.
378
378
+
const WalkCtx = struct {
379
379
+
arena: Allocator,
380
380
+
car_bytes: []const u8,
381
381
+
index: BlockIndex,
382
382
+
did: []const u8,
383
383
+
filter: WalkFilter,
384
384
+
out: ?*std.ArrayList(Record),
385
385
+
kept: usize = 0,
386
386
+
skipped_by_collection: usize = 0,
387
387
+
skipped_by_time: usize = 0,
388
388
+
};
389
389
+
390
390
+
const FilterDecision = enum { kept, skipped_collection, skipped_time };
391
391
+
392
392
+
fn applyFilter(filter: WalkFilter, collection_name: []const u8, rkey: []const u8) FilterDecision {
393
393
+
for (filter.skip_collections) |sc| {
394
394
+
if (std.mem.eql(u8, collection_name, sc)) return .skipped_collection;
395
395
+
}
396
396
+
if (filter.min_rkey_tid_us) |cutoff| {
397
397
+
if (decodeTidMicros(rkey)) |tid_us| {
398
398
+
if (tid_us < cutoff) return .skipped_time;
399
399
+
}
400
400
+
// non-TID rkeys fall through as kept — profile `self`, etc.
401
401
+
}
402
402
+
return .kept;
403
403
+
}
404
404
+
405
405
+
fn walkNode(ctx: *WalkCtx, node_cid: []const u8) WalkError!void {
406
406
+
const node_data = indexLookup(ctx.car_bytes, ctx.index, node_cid) orelse return error.BlockNotFound;
407
407
+
const node = zat.mst.decodeMstNode(ctx.arena, node_data) catch return error.InvalidMstNode;
408
408
+
409
409
+
// MST invariant: left subtree holds keys strictly less than every key
410
410
+
// in this node. walk it first so the output ends up in lexicographic
411
411
+
// order (same contract as pds.listRecords, alphabetical by collection).
412
412
+
if (node.left) |left_cid| try walkNode(ctx, left_cid);
413
413
+
414
414
+
// prefix-compressed keys: entries[i].key = entries[i-1].key[0..prefix_len] ++ suffix.
415
415
+
// a 512-byte reconstruction buffer is plenty — atproto MST keys are
416
416
+
// `{collection}/{rkey}`, which maxes out around 128 bytes in practice.
417
417
+
var key_buf: [512]u8 = undefined;
418
418
+
for (node.entries) |entry| {
419
419
+
if (entry.prefix_len + entry.key_suffix.len > key_buf.len) continue;
420
420
+
@memcpy(key_buf[entry.prefix_len..][0..entry.key_suffix.len], entry.key_suffix);
421
421
+
const key = key_buf[0 .. entry.prefix_len + entry.key_suffix.len];
422
422
+
423
423
+
const slash = std.mem.indexOfScalar(u8, key, '/') orelse continue;
424
424
+
const collection_name = key[0..slash];
425
425
+
const rkey = key[slash + 1 ..];
426
426
+
427
427
+
switch (applyFilter(ctx.filter, collection_name, rkey)) {
428
428
+
.skipped_collection => ctx.skipped_by_collection += 1,
429
429
+
.skipped_time => ctx.skipped_by_time += 1,
430
430
+
.kept => {
431
431
+
ctx.kept += 1;
432
432
+
if (ctx.out) |out| {
433
433
+
// full emit: decode value, dupe strings, append. on
434
434
+
// decode failure preserve old behavior and skip the
435
435
+
// right subtree — `continue` bypasses the tree walk
436
436
+
// at the bottom of the loop.
437
437
+
const collection = ctx.arena.dupe(u8, collection_name) catch return error.OutOfMemory;
438
438
+
const value_data = indexLookup(ctx.car_bytes, ctx.index, entry.value) orelse continue;
439
439
+
const value_cbor = zat.cbor.decodeAll(ctx.arena, value_data) catch continue;
440
440
+
const value_json = try cborToJson(ctx.arena, value_cbor);
441
441
+
442
442
+
const uri = std.fmt.allocPrint(ctx.arena, "at://{s}/{s}/{s}", .{ ctx.did, collection, rkey }) catch return error.OutOfMemory;
443
443
+
const cid_str = zat.multibase.encode(ctx.arena, .base32lower, entry.value) catch return error.OutOfMemory;
444
444
+
445
445
+
try out.append(ctx.arena, .{
446
446
+
.uri = uri,
447
447
+
.cid = cid_str,
448
448
+
.collection = collection,
449
449
+
.value = value_json,
450
450
+
});
451
451
+
}
452
452
+
},
453
453
+
}
454
454
+
455
455
+
// right subtree of THIS entry (keys between this one and the next)
456
456
+
if (entry.tree) |tree_cid| try walkNode(ctx, tree_cid);
457
457
+
}
458
458
+
}
459
459
+
460
460
+
/// Decode an atproto TID rkey (13 base32-sortable chars) to a unix
461
461
+
/// microsecond timestamp. Returns null for non-TID rkeys — profile
462
462
+
/// `self`, app.bsky.actor.declaration `self`, etc. Callers should treat
463
463
+
/// a null as "unfilterable, keep by default".
464
464
+
///
465
465
+
/// TID layout: 64 bits, top bit always 0 (sort-safe), next 53 bits =
466
466
+
/// microseconds since unix epoch, bottom 10 bits = clock id.
467
467
+
/// Alphabet: `234567abcdefghijklmnopqrstuvwxyz`.
468
468
+
///
469
469
+
/// public so the listRecords fallback in indexer.zig can apply the
470
470
+
/// same time cutoff as the CAR walker for large repos.
471
471
+
pub fn decodeTidMicros(rkey: []const u8) ?i64 {
472
472
+
if (rkey.len != 13) return null;
473
473
+
var val: u64 = 0;
474
474
+
for (rkey) |c| {
475
475
+
const digit: u64 = switch (c) {
476
476
+
'2'...'7' => @intCast(c - '2'),
477
477
+
'a'...'z' => @intCast(@as(u32, c - 'a') + 6),
478
478
+
else => return null,
479
479
+
};
480
480
+
val = (val << 5) | digit;
481
481
+
}
482
482
+
if (val >> 63 != 0) return null; // top bit must be 0
483
483
+
const us = (val >> 10) & ((@as(u64, 1) << 53) - 1);
484
484
+
return @intCast(us);
485
485
+
}
486
486
+
487
487
+
test "decodeTidMicros: known TID round trip" {
488
488
+
// 3juj-5m5xfs2v = ??? — use a TID encoding a specific time
489
489
+
// pick unix epoch 2023-06-01T00:00:00Z = 1685577600 seconds
490
490
+
// = 1685577600000000 microseconds
491
491
+
// shift left 10 bits = 1725031526400000000 << 10 actually wait
492
492
+
// let's encode then decode for self-consistency instead
493
493
+
const us: u64 = 1685577600000000;
494
494
+
const clock: u64 = 0;
495
495
+
var val: u64 = (us << 10) | clock;
496
496
+
// encode to 13 base32-sortable chars
497
497
+
var buf: [13]u8 = undefined;
498
498
+
var i: usize = 13;
499
499
+
while (i > 0) {
500
500
+
i -= 1;
501
501
+
const d: u8 = @intCast(val & 0x1f);
502
502
+
buf[i] = if (d < 6) '2' + d else 'a' + (d - 6);
503
503
+
val >>= 5;
504
504
+
}
505
505
+
const decoded = decodeTidMicros(&buf) orelse return error.DecodeFailed;
506
506
+
try std.testing.expectEqual(@as(i64, @intCast(us)), decoded);
507
507
+
}
508
508
+
509
509
+
test "decodeTidMicros: non-TID rkey returns null" {
510
510
+
try std.testing.expectEqual(@as(?i64, null), decodeTidMicros("self"));
511
511
+
try std.testing.expectEqual(@as(?i64, null), decodeTidMicros(""));
512
512
+
try std.testing.expectEqual(@as(?i64, null), decodeTidMicros("too-long-for-a-tid-abc"));
513
513
+
}
514
514
+
515
515
+
/// Convert a zat.cbor.Value into a std.json.Value. This exists so records
516
516
+
/// pulled through the CAR walker can flow through the same record_text /
517
517
+
/// display pipeline as records from pds.listRecords (which returns
518
518
+
/// json.Value already, since listRecords is a JSON XRPC endpoint).
519
519
+
///
520
520
+
/// dropped: byte strings and CID refs. record_text treats them as noise
521
521
+
/// anyway — CID-shaped strings get filtered by isIdentifier, and raw bytes
522
522
+
/// don't contribute semantic signal.
523
523
+
fn cborToJson(arena: Allocator, cv: zat.cbor.Value) WalkError!json.Value {
524
524
+
return switch (cv) {
525
525
+
.unsigned => |u| .{ .integer = std.math.cast(i64, u) orelse return .{ .null = {} } },
526
526
+
.negative => |n| .{ .integer = n },
527
527
+
.text => |t| .{ .string = arena.dupe(u8, t) catch return error.OutOfMemory },
528
528
+
.boolean => |b| .{ .bool = b },
529
529
+
.null => .{ .null = {} },
530
530
+
.bytes => .{ .null = {} },
531
531
+
.cid => .{ .null = {} },
532
532
+
.array => |arr| blk: {
533
533
+
var items: json.Array = .init(arena);
534
534
+
items.ensureTotalCapacity(arr.len) catch return error.OutOfMemory;
535
535
+
for (arr) |v| items.appendAssumeCapacity(try cborToJson(arena, v));
536
536
+
break :blk .{ .array = items };
537
537
+
},
538
538
+
.map => |entries| blk: {
539
539
+
var obj: json.ObjectMap = .{};
540
540
+
obj.ensureTotalCapacity(arena, @intCast(entries.len)) catch return error.OutOfMemory;
541
541
+
for (entries) |e| {
542
542
+
const key_copy = arena.dupe(u8, e.key) catch return error.OutOfMemory;
543
543
+
obj.putAssumeCapacity(key_copy, try cborToJson(arena, e.value));
544
544
+
}
545
545
+
break :blk .{ .object = obj };
546
546
+
},
547
547
+
};
548
548
+
}
···
1
1
+
const std = @import("std");
2
2
+
3
3
+
pub const pds = @import("pds.zig");
4
4
+
pub const repo_walk = @import("repo_walk.zig");
5
5
+
pub const record_text = @import("record_text.zig");
6
6
+
7
7
+
pub const Kind = enum {
8
8
+
text,
9
9
+
image_ref,
10
10
+
video_ref,
11
11
+
12
12
+
pub fn jsonString(self: Kind) []const u8 {
13
13
+
return switch (self) {
14
14
+
.text => "text",
15
15
+
.image_ref => "image_ref",
16
16
+
.video_ref => "video_ref",
17
17
+
};
18
18
+
}
19
19
+
};
20
20
+
21
21
+
pub const BlobRef = struct {
22
22
+
cid: []const u8,
23
23
+
mime_type: ?[]const u8 = null,
24
24
+
alt: ?[]const u8 = null,
25
25
+
};
26
26
+
27
27
+
pub const EmbeddableItem = struct {
28
28
+
kind: Kind,
29
29
+
uri: []const u8,
30
30
+
cid: []const u8,
31
31
+
collection: []const u8,
32
32
+
author_did: []const u8,
33
33
+
created_at: ?[]const u8 = null,
34
34
+
text: ?[]const u8 = null,
35
35
+
blob: ?BlobRef = null,
36
36
+
};
37
37
+
38
38
+
pub const ExtractOptions = struct {
39
39
+
include_media_refs: bool = false,
40
40
+
skip_collections: []const []const u8 = &.{ "app.bsky.feed.like", "app.bsky.graph.follow" },
41
41
+
min_rkey_tid_us: ?i64 = null,
42
42
+
};
43
43
+
44
44
+
pub fn extractRecords(
45
45
+
arena: std.mem.Allocator,
46
46
+
did: []const u8,
47
47
+
records: []const repo_walk.Record,
48
48
+
options: ExtractOptions,
49
49
+
) ![]EmbeddableItem {
50
50
+
var out: std.ArrayList(EmbeddableItem) = .empty;
51
51
+
for (records) |record| {
52
52
+
const text = try record_text.recordToText(arena, record.collection, record.value);
53
53
+
if (std.mem.trim(u8, text, " \t\r\n").len > 0) {
54
54
+
try out.append(arena, .{
55
55
+
.kind = .text,
56
56
+
.uri = record.uri,
57
57
+
.cid = record.cid,
58
58
+
.collection = record.collection,
59
59
+
.author_did = did,
60
60
+
.created_at = findString(record.value, "createdAt"),
61
61
+
.text = text,
62
62
+
});
63
63
+
}
64
64
+
65
65
+
if (options.include_media_refs) {
66
66
+
try collectMediaRefs(arena, &out, did, record);
67
67
+
}
68
68
+
}
69
69
+
return out.toOwnedSlice(arena);
70
70
+
}
71
71
+
72
72
+
pub fn filterFromOptions(options: ExtractOptions) repo_walk.WalkFilter {
73
73
+
return .{
74
74
+
.skip_collections = options.skip_collections,
75
75
+
.min_rkey_tid_us = options.min_rkey_tid_us,
76
76
+
};
77
77
+
}
78
78
+
79
79
+
fn collectMediaRefs(
80
80
+
arena: std.mem.Allocator,
81
81
+
out: *std.ArrayList(EmbeddableItem),
82
82
+
did: []const u8,
83
83
+
record: repo_walk.Record,
84
84
+
) !void {
85
85
+
const embed = getPath(record.value, &.{ "embed" }) orelse return;
86
86
+
const embed_type = findString(embed, "$type") orelse "";
87
87
+
88
88
+
if (std.mem.eql(u8, embed_type, "app.bsky.embed.images")) {
89
89
+
const images = getPath(embed, &.{ "images" }) orelse return;
90
90
+
if (images != .array) return;
91
91
+
for (images.array.items) |image| {
92
92
+
const ref = blobRefFromObject(image, "image") orelse continue;
93
93
+
try out.append(arena, mediaItem(.image_ref, did, record, ref));
94
94
+
}
95
95
+
} else if (std.mem.eql(u8, embed_type, "app.bsky.embed.video")) {
96
96
+
const ref = blobRefFromObject(embed, "video") orelse return;
97
97
+
try out.append(arena, mediaItem(.video_ref, did, record, ref));
98
98
+
} else if (std.mem.eql(u8, embed_type, "app.bsky.embed.recordWithMedia")) {
99
99
+
const media = getPath(embed, &.{ "media" }) orelse return;
100
100
+
const media_type = findString(media, "$type") orelse "";
101
101
+
if (std.mem.eql(u8, media_type, "app.bsky.embed.images")) {
102
102
+
const images = getPath(media, &.{ "images" }) orelse return;
103
103
+
if (images != .array) return;
104
104
+
for (images.array.items) |image| {
105
105
+
const ref = blobRefFromObject(image, "image") orelse continue;
106
106
+
try out.append(arena, mediaItem(.image_ref, did, record, ref));
107
107
+
}
108
108
+
} else if (std.mem.eql(u8, media_type, "app.bsky.embed.video")) {
109
109
+
const ref = blobRefFromObject(media, "video") orelse return;
110
110
+
try out.append(arena, mediaItem(.video_ref, did, record, ref));
111
111
+
}
112
112
+
}
113
113
+
}
114
114
+
115
115
+
fn mediaItem(kind: Kind, did: []const u8, record: repo_walk.Record, ref: BlobRef) EmbeddableItem {
116
116
+
return .{
117
117
+
.kind = kind,
118
118
+
.uri = record.uri,
119
119
+
.cid = record.cid,
120
120
+
.collection = record.collection,
121
121
+
.author_did = did,
122
122
+
.created_at = findString(record.value, "createdAt"),
123
123
+
.blob = ref,
124
124
+
};
125
125
+
}
126
126
+
127
127
+
fn blobRefFromObject(value: std.json.Value, field: []const u8) ?BlobRef {
128
128
+
if (value != .object) return null;
129
129
+
const blob = value.object.get(field) orelse return null;
130
130
+
if (blob != .object) return null;
131
131
+
const ref = blob.object.get("ref") orelse blob.object.get("cid") orelse return null;
132
132
+
const cid = switch (ref) {
133
133
+
.string => |s| s,
134
134
+
.object => |obj| blk: {
135
135
+
const link = obj.get("$link") orelse return null;
136
136
+
if (link != .string) return null;
137
137
+
break :blk link.string;
138
138
+
},
139
139
+
else => return null,
140
140
+
};
141
141
+
return .{
142
142
+
.cid = cid,
143
143
+
.mime_type = findString(blob, "mimeType"),
144
144
+
.alt = findString(value, "alt"),
145
145
+
};
146
146
+
}
147
147
+
148
148
+
fn getPath(value: std.json.Value, path: []const []const u8) ?std.json.Value {
149
149
+
var cur = value;
150
150
+
for (path) |part| {
151
151
+
if (cur != .object) return null;
152
152
+
cur = cur.object.get(part) orelse return null;
153
153
+
}
154
154
+
return cur;
155
155
+
}
156
156
+
157
157
+
fn findString(value: std.json.Value, key: []const u8) ?[]const u8 {
158
158
+
if (value != .object) return null;
159
159
+
const v = value.object.get(key) orelse return null;
160
160
+
if (v != .string) return null;
161
161
+
return v.string;
162
162
+
}
163
163
+
164
164
+
test "filter defaults skip low-signal social records" {
165
165
+
const filter = filterFromOptions(.{});
166
166
+
try std.testing.expectEqual(@as(usize, 2), filter.skip_collections.len);
167
167
+
}
···
1
1
+
import {
2
2
+
CompositeDidDocumentResolver,
3
3
+
LocalActorResolver,
4
4
+
PlcDidDocumentResolver,
5
5
+
WebDidDocumentResolver,
6
6
+
XrpcHandleResolver
7
7
+
} from "@atcute/identity-resolver";
8
8
+
import { fromStream as repoFromStream } from "@atcute/repo";
9
9
+
10
10
+
const VOYAGE_URL = "https://api.voyageai.com/v1/embeddings";
11
11
+
const TPUF_BASE = "https://api.turbopuffer.com/v2/namespaces";
12
12
+
const TYPEAHEAD_URL = "https://typeahead.waow.tech/xrpc/app.bsky.actor.searchActorsTypeahead";
13
13
+
const MODEL = "voyage-4-lite";
14
14
+
const DIMENSIONS = 1024;
15
15
+
const INDEX_LIMIT = 5000;
16
16
+
const INDEX_BATCH_SIZE = 96;
17
17
+
const MAX_TEXT_LEN = 4000;
18
18
+
const ARTIFACT_ATTRS = [
19
19
+
"artifactKind",
20
20
+
"artifactTitle",
21
21
+
"artifactBody",
22
22
+
"artifactUrl",
23
23
+
"artifactMedia",
24
24
+
"artifactRefs",
25
25
+
"artifactDate",
26
26
+
"artifactConfidence"
27
27
+
];
28
28
+
29
29
+
const actorResolver = new LocalActorResolver({
30
30
+
handleResolver: new XrpcHandleResolver({
31
31
+
serviceUrl: "https://public.api.bsky.app"
32
32
+
}),
33
33
+
didDocumentResolver: new CompositeDidDocumentResolver({
34
34
+
methods: {
35
35
+
plc: new PlcDidDocumentResolver(),
36
36
+
web: new WebDidDocumentResolver()
37
37
+
}
38
38
+
})
39
39
+
});
40
40
+
41
41
+
export default {
42
42
+
async fetch(request, env) {
43
43
+
const url = new URL(request.url);
44
44
+
45
45
+
if (url.pathname === "/api/search") return search(request, env, url);
46
46
+
if (url.pathname === "/api/update") return update(request, env, url);
47
47
+
if (url.pathname === "/api/actors") return actors(request, url);
48
48
+
if (url.pathname === "/health") return json({ ok: true, name: "Pensieve" });
49
49
+
50
50
+
return new Response(renderIndex(), {
51
51
+
headers: {
52
52
+
"content-type": "text/html; charset=utf-8",
53
53
+
"cache-control": "no-store"
54
54
+
}
55
55
+
});
56
56
+
}
57
57
+
};
58
58
+
59
59
+
async function actors(request, url) {
60
60
+
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
61
61
+
62
62
+
const q = (url.searchParams.get("q") || "").trim();
63
63
+
if (!q) return json({ actors: [] });
64
64
+
65
65
+
const response = await fetch(`${TYPEAHEAD_URL}?q=${encodeURIComponent(q)}&limit=6`, {
66
66
+
headers: { "x-client": "pensieve" }
67
67
+
});
68
68
+
const body = await response.json().catch(() => ({ actors: [] }));
69
69
+
return json({ actors: Array.isArray(body.actors) ? body.actors : [] });
70
70
+
}
71
71
+
72
72
+
async function search(request, env, url) {
73
73
+
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
74
74
+
75
75
+
const query = (url.searchParams.get("q") || "").trim();
76
76
+
const actor = (url.searchParams.get("actor") || "").replace(/^@+/, "").trim().toLowerCase();
77
77
+
const topK = clamp(Number(url.searchParams.get("top_k") || 8), 1, 20);
78
78
+
79
79
+
if (!query) return json({ error: "missing_query", message: "Tell Pensieve what you are trying to remember." }, 400);
80
80
+
if (!actor) return json({ error: "missing_actor", message: "Enter a name or handle first." }, 400);
81
81
+
if (!env.VOYAGE_API_KEY || !env.TURBOPUFFER_API_KEY) {
82
82
+
return json({ error: "missing_worker_secret", message: "Search is not configured yet." }, 500);
83
83
+
}
84
84
+
85
85
+
try {
86
86
+
const identity = await resolveIdentity(actor);
87
87
+
const namespace = actorNamespace(env, identity.did);
88
88
+
const vector = await embed(query, env.VOYAGE_API_KEY);
89
89
+
const body = await queryNamespace(namespace, query, vector, topK, env.TURBOPUFFER_API_KEY);
90
90
+
91
91
+
return json({
92
92
+
query,
93
93
+
actor: identity.handle || actor,
94
94
+
did: identity.did,
95
95
+
indexed: null,
96
96
+
needsIndex: body.missing,
97
97
+
results: readableResults(body.rows, topK)
98
98
+
});
99
99
+
} catch (error) {
100
100
+
console.error(JSON.stringify({
101
101
+
event: "search_failed",
102
102
+
actor,
103
103
+
message: error?.message || String(error),
104
104
+
stack: error?.stack || ""
105
105
+
}));
106
106
+
return json({ error: "search_unavailable", message: "Search is temporarily unavailable." }, 502);
107
107
+
}
108
108
+
}
109
109
+
110
110
+
async function update(request, env, url) {
111
111
+
if (request.method !== "GET") return json({ error: "method_not_allowed" }, 405);
112
112
+
113
113
+
const query = (url.searchParams.get("q") || "").trim();
114
114
+
const actor = (url.searchParams.get("actor") || "").replace(/^@+/, "").trim().toLowerCase();
115
115
+
const topK = clamp(Number(url.searchParams.get("top_k") || 8), 1, 20);
116
116
+
117
117
+
if (!query) return json({ error: "missing_query", message: "Write what you are trying to find." }, 400);
118
118
+
if (!actor) return json({ error: "missing_actor", message: "Enter a name or handle first." }, 400);
119
119
+
if (!env.VOYAGE_API_KEY || !env.TURBOPUFFER_API_KEY) {
120
120
+
return json({ error: "missing_worker_secret", message: "Search is not configured yet." }, 500);
121
121
+
}
122
122
+
123
123
+
const stream = new TransformStream();
124
124
+
const writer = stream.writable.getWriter();
125
125
+
const encoder = new TextEncoder();
126
126
+
127
127
+
const emit = async (event) => {
128
128
+
await writer.write(encoder.encode(`${JSON.stringify(event)}\n`));
129
129
+
};
130
130
+
131
131
+
(async () => {
132
132
+
try {
133
133
+
await emit({ type: "progress", phase: "resolve", label: "resolving handle", pct: 2 });
134
134
+
const identity = await resolveIdentity(actor);
135
135
+
const namespace = actorNamespace(env, identity.did);
136
136
+
137
137
+
const indexed = await indexActorFromCar(identity, namespace, env, {
138
138
+
replace: true,
139
139
+
onProgress: emit
140
140
+
});
141
141
+
142
142
+
await emit({ type: "progress", phase: "query", label: "searching rebuilt index", pct: 96 });
143
143
+
const vector = await embed(query, env.VOYAGE_API_KEY);
144
144
+
const body = await queryNamespace(namespace, query, vector, topK, env.TURBOPUFFER_API_KEY);
145
145
+
146
146
+
await emit({
147
147
+
type: "done",
148
148
+
data: {
149
149
+
query,
150
150
+
actor: identity.handle || actor,
151
151
+
did: identity.did,
152
152
+
indexed,
153
153
+
needsIndex: false,
154
154
+
results: readableResults(body.rows, topK)
155
155
+
}
156
156
+
});
157
157
+
} catch (error) {
158
158
+
console.error(JSON.stringify({
159
159
+
event: "update_failed",
160
160
+
actor,
161
161
+
message: error?.message || String(error),
162
162
+
stack: error?.stack || ""
163
163
+
}));
164
164
+
await emit({ type: "error", message: "Pensieve could not read that public history." });
165
165
+
} finally {
166
166
+
await writer.close();
167
167
+
}
168
168
+
})();
169
169
+
170
170
+
return new Response(stream.readable, {
171
171
+
headers: {
172
172
+
"content-type": "application/x-ndjson; charset=utf-8",
173
173
+
"cache-control": "no-store"
174
174
+
}
175
175
+
});
176
176
+
}
177
177
+
178
178
+
async function queryNamespace(namespace, query, vector, topK, apiKey) {
179
179
+
const [vectorResult, textResult] = await Promise.all([
180
180
+
queryVectorNamespace(namespace, vector, topK * 3, apiKey),
181
181
+
queryTextNamespace(namespace, query, topK * 3, apiKey)
182
182
+
]);
183
183
+
184
184
+
return {
185
185
+
missing: vectorResult.missing && textResult.missing,
186
186
+
rows: fuseRows(vectorResult.rows, textResult.rows, topK)
187
187
+
};
188
188
+
}
189
189
+
190
190
+
async function queryVectorNamespace(namespace, vector, topK, apiKey) {
191
191
+
const response = await fetch(`${TPUF_BASE}/${encodeURIComponent(namespace)}/query`, {
192
192
+
method: "POST",
193
193
+
headers: {
194
194
+
"authorization": `Bearer ${apiKey}`,
195
195
+
"content-type": "application/json"
196
196
+
},
197
197
+
body: JSON.stringify({
198
198
+
rank_by: ["vector", "ANN", vector],
199
199
+
top_k: topK,
200
200
+
include_attributes: true
201
201
+
})
202
202
+
});
203
203
+
204
204
+
const body = await response.json().catch(() => ({}));
205
205
+
if (response.status === 404) return { missing: true, rows: [] };
206
206
+
if (!response.ok) throw new Error("query_failed");
207
207
+
return { missing: false, rows: Array.isArray(body.rows) ? body.rows : [] };
208
208
+
}
209
209
+
210
210
+
async function queryTextNamespace(namespace, query, topK, apiKey) {
211
211
+
const response = await fetch(`${TPUF_BASE}/${encodeURIComponent(namespace)}/query`, {
212
212
+
method: "POST",
213
213
+
headers: {
214
214
+
"authorization": `Bearer ${apiKey}`,
215
215
+
"content-type": "application/json"
216
216
+
},
217
217
+
body: JSON.stringify({
218
218
+
rank_by: ["text", "BM25", query],
219
219
+
top_k: topK,
220
220
+
include_attributes: true
221
221
+
})
222
222
+
});
223
223
+
224
224
+
const body = await response.json().catch(() => ({}));
225
225
+
if (response.status === 404) return { missing: true, rows: [] };
226
226
+
if (!response.ok) {
227
227
+
console.warn(JSON.stringify({
228
228
+
event: "bm25_query_failed",
229
229
+
status: response.status,
230
230
+
detail: JSON.stringify(body).slice(0, 300)
231
231
+
}));
232
232
+
return { missing: false, rows: [] };
233
233
+
}
234
234
+
return { missing: false, rows: Array.isArray(body.rows) ? body.rows : [] };
235
235
+
}
236
236
+
237
237
+
function fuseRows(vectorRows, textRows, topK) {
238
238
+
const byId = new Map();
239
239
+
addRankedRows(byId, vectorRows, "vectorRank", 1.0);
240
240
+
addRankedRows(byId, textRows, "textRank", 1.35);
241
241
+
242
242
+
return Array.from(byId.values())
243
243
+
.sort((a, b) => b.score - a.score)
244
244
+
.slice(0, topK)
245
245
+
.map(({ row }) => row);
246
246
+
}
247
247
+
248
248
+
function addRankedRows(byId, rows, rankName, weight) {
249
249
+
rows.forEach((row, index) => {
250
250
+
const id = row.id || row.uri || String(index);
251
251
+
const current = byId.get(id) || { row, score: 0 };
252
252
+
current.row = { ...current.row, ...row };
253
253
+
current[rankName] = index + 1;
254
254
+
current.score += weight / (60 + index + 1);
255
255
+
byId.set(id, current);
256
256
+
});
257
257
+
}
258
258
+
259
259
+
async function embed(text, apiKey) {
260
260
+
return (await embedMany([text], "query", apiKey))[0];
261
261
+
}
262
262
+
263
263
+
async function embedMany(texts, inputType, apiKey) {
264
264
+
const response = await fetch(VOYAGE_URL, {
265
265
+
method: "POST",
266
266
+
headers: {
267
267
+
"authorization": `Bearer ${apiKey}`,
268
268
+
"content-type": "application/json"
269
269
+
},
270
270
+
body: JSON.stringify({
271
271
+
model: MODEL,
272
272
+
input_type: inputType,
273
273
+
output_dimension: DIMENSIONS,
274
274
+
input: texts
275
275
+
})
276
276
+
});
277
277
+
278
278
+
const body = await response.json().catch(() => ({}));
279
279
+
if (!response.ok || !Array.isArray(body.data)) throw new Error("embedding_failed");
280
280
+
return body.data.map((row) => row.embedding);
281
281
+
}
282
282
+
283
283
+
async function resolveIdentity(actor) {
284
284
+
return actorResolver.resolve(actor);
285
285
+
}
286
286
+
287
287
+
async function indexActorFromCar(identity, namespace, env, options = {}) {
288
288
+
const onProgress = options.onProgress || (async () => {});
289
289
+
if (options.replace) {
290
290
+
await onProgress({ type: "progress", phase: "prepare", label: "preparing index update", pct: 5 });
291
291
+
await deleteNamespace(namespace, env.TURBOPUFFER_API_KEY);
292
292
+
}
293
293
+
294
294
+
await onProgress({ type: "progress", phase: "fetch", label: "fetching repo CAR", pct: 8 });
295
295
+
const getRepoUrl = new URL("/xrpc/com.atproto.sync.getRepo", identity.pds);
296
296
+
getRepoUrl.searchParams.set("did", identity.did);
297
297
+
const response = await fetch(getRepoUrl);
298
298
+
if (!response.ok || !response.body) {
299
299
+
throw new Error(`get_repo_failed:${response.status}:${identity.pds}`);
300
300
+
}
301
301
+
302
302
+
const repo = repoFromStream(response.body);
303
303
+
const items = [];
304
304
+
const stats = {
305
305
+
scanned: 0,
306
306
+
indexed: 0,
307
307
+
skippedNoText: 0,
308
308
+
missingBlocks: 0
309
309
+
};
310
310
+
311
311
+
try {
312
312
+
for await (const entry of repo) {
313
313
+
stats.scanned += 1;
314
314
+
if (stats.scanned === 1 || stats.scanned % 250 === 0) {
315
315
+
await onProgress({
316
316
+
type: "progress",
317
317
+
phase: "walk",
318
318
+
label: `walking CAR · scanned ${stats.scanned.toLocaleString()} records`,
319
319
+
scanned: stats.scanned,
320
320
+
indexed: items.length,
321
321
+
pct: Math.min(38, 10 + Math.floor(stats.scanned / 250))
322
322
+
});
323
323
+
}
324
324
+
325
325
+
const artifact = inferArtifact(entry.collection, entry.record);
326
326
+
const text = artifactSearchText(entry.collection, artifact);
327
327
+
if (!text.trim()) {
328
328
+
stats.skippedNoText += 1;
329
329
+
continue;
330
330
+
}
331
331
+
332
332
+
items.push({
333
333
+
uri: `at://${identity.did}/${entry.collection}/${entry.rkey}`,
334
334
+
cid: cidString(entry.cid),
335
335
+
collection: entry.collection,
336
336
+
authorDid: identity.did,
337
337
+
createdAt: findIsoDate(entry.record),
338
338
+
text,
339
339
+
artifact
340
340
+
});
341
341
+
342
342
+
if (items.length >= INDEX_LIMIT) break;
343
343
+
}
344
344
+
} finally {
345
345
+
stats.missingBlocks = repo.missingBlocks.length;
346
346
+
await repo.dispose();
347
347
+
}
348
348
+
349
349
+
await onProgress({
350
350
+
type: "progress",
351
351
+
phase: "embed",
352
352
+
label: `embedding ${items.length.toLocaleString()} searchable records`,
353
353
+
scanned: stats.scanned,
354
354
+
indexed: 0,
355
355
+
total: items.length,
356
356
+
pct: 42
357
357
+
});
358
358
+
359
359
+
for (let i = 0; i < items.length; i += INDEX_BATCH_SIZE) {
360
360
+
const batch = items.slice(i, i + INDEX_BATCH_SIZE);
361
361
+
const vectors = await embedMany(batch.map((item) => item.text), "document", env.VOYAGE_API_KEY);
362
362
+
const rows = batch.map((item, index) => ({
363
363
+
id: rowId(item.uri),
364
364
+
vector: vectors[index],
365
365
+
uri: item.uri,
366
366
+
cid: item.cid,
367
367
+
collection: item.collection,
368
368
+
authorDid: item.authorDid,
369
369
+
createdAt: item.createdAt,
370
370
+
text: item.text,
371
371
+
artifactKind: item.artifact.kind,
372
372
+
artifactTitle: item.artifact.title,
373
373
+
artifactBody: item.artifact.body,
374
374
+
artifactUrl: item.artifact.url,
375
375
+
artifactMedia: JSON.stringify(item.artifact.media || []),
376
376
+
artifactRefs: JSON.stringify(item.artifact.refs || []),
377
377
+
artifactDate: item.artifact.date || item.createdAt,
378
378
+
artifactConfidence: String(item.artifact.confidence || "low")
379
379
+
}));
380
380
+
381
381
+
const writeBody = {
382
382
+
distance_metric: "cosine_distance",
383
383
+
schema: {
384
384
+
text: {
385
385
+
type: "string",
386
386
+
full_text_search: true
387
387
+
},
388
388
+
artifactKind: {
389
389
+
type: "string"
390
390
+
},
391
391
+
artifactTitle: {
392
392
+
type: "string",
393
393
+
full_text_search: true
394
394
+
},
395
395
+
artifactBody: {
396
396
+
type: "string",
397
397
+
full_text_search: true
398
398
+
},
399
399
+
artifactUrl: {
400
400
+
type: "string"
401
401
+
},
402
402
+
artifactMedia: {
403
403
+
type: "string"
404
404
+
},
405
405
+
artifactRefs: {
406
406
+
type: "string"
407
407
+
},
408
408
+
artifactDate: {
409
409
+
type: "string"
410
410
+
},
411
411
+
artifactConfidence: {
412
412
+
type: "string"
413
413
+
}
414
414
+
},
415
415
+
upsert_rows: rows
416
416
+
};
417
417
+
418
418
+
const upsert = await fetch(`${TPUF_BASE}/${encodeURIComponent(namespace)}`, {
419
419
+
method: "POST",
420
420
+
headers: {
421
421
+
"authorization": `Bearer ${env.TURBOPUFFER_API_KEY}`,
422
422
+
"content-type": "application/json"
423
423
+
},
424
424
+
body: JSON.stringify(writeBody)
425
425
+
});
426
426
+
if (!upsert.ok) {
427
427
+
const detail = await upsert.text().catch(() => "");
428
428
+
throw new Error(`index_failed:${upsert.status}:${detail.slice(0, 500)}`);
429
429
+
}
430
430
+
stats.indexed += rows.length;
431
431
+
await onProgress({
432
432
+
type: "progress",
433
433
+
phase: "embed",
434
434
+
label: `embedded ${stats.indexed.toLocaleString()} / ${items.length.toLocaleString()} records`,
435
435
+
scanned: stats.scanned,
436
436
+
indexed: stats.indexed,
437
437
+
total: items.length,
438
438
+
pct: 42 + Math.round((stats.indexed / Math.max(items.length, 1)) * 50)
439
439
+
});
440
440
+
}
441
441
+
442
442
+
return stats;
443
443
+
}
444
444
+
445
445
+
async function deleteNamespace(namespace, apiKey) {
446
446
+
const response = await fetch(`${TPUF_BASE}/${encodeURIComponent(namespace)}`, {
447
447
+
method: "DELETE",
448
448
+
headers: {
449
449
+
"authorization": `Bearer ${apiKey}`
450
450
+
}
451
451
+
});
452
452
+
if (response.ok || response.status === 404) return;
453
453
+
const detail = await response.text().catch(() => "");
454
454
+
throw new Error(`delete_namespace_failed:${response.status}:${detail.slice(0, 300)}`);
455
455
+
}
456
456
+
457
457
+
function actorNamespace(env, did) {
458
458
+
const prefix = env.TURBOPUFFER_NAMESPACE_PREFIX || "pensieve";
459
459
+
return `${prefix}-${did.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
460
460
+
}
461
461
+
462
462
+
function cidString(cid) {
463
463
+
if (typeof cid === "string") return cid;
464
464
+
if (cid?.$link) return cid.$link;
465
465
+
return String(cid);
466
466
+
}
467
467
+
468
468
+
function rowId(uri) {
469
469
+
let hash = 2166136261;
470
470
+
for (let i = 0; i < uri.length; i += 1) {
471
471
+
hash ^= uri.charCodeAt(i);
472
472
+
hash = Math.imul(hash, 16777619);
473
473
+
}
474
474
+
return `${hash >>> 0}-${uri.slice(-48).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
475
475
+
}
476
476
+
477
477
+
function inferArtifact(collection, value) {
478
478
+
const fields = [];
479
479
+
const urls = [];
480
480
+
const refs = [];
481
481
+
const media = [];
482
482
+
const dates = [];
483
483
+
walkArtifact(value, "", { fields, urls, refs, media, dates });
484
484
+
485
485
+
const title = pickField(fields, titleScore);
486
486
+
const body = pickField(fields, (field) => bodyScore(field, title?.value));
487
487
+
const url = pickUrl(urls);
488
488
+
const date = pickDate(dates) || findIsoDate(value);
489
489
+
const titleValue = title?.value || "";
490
490
+
const bodyValue = body?.value && body.value !== titleValue ? body.value : "";
491
491
+
const confidence = titleValue || bodyValue ? "medium" : "low";
492
492
+
493
493
+
return {
494
494
+
kind: collectionKind(collection),
495
495
+
title: titleValue,
496
496
+
body: bodyValue,
497
497
+
url: url?.value || "",
498
498
+
media: media.slice(0, 4),
499
499
+
refs: refs.slice(0, 6),
500
500
+
date,
501
501
+
confidence
502
502
+
};
503
503
+
}
504
504
+
505
505
+
function artifactSearchText(collection, artifact) {
506
506
+
const lines = [
507
507
+
`collection: ${collection}`,
508
508
+
`kind: ${artifact.kind || collectionKind(collection)}`,
509
509
+
artifact.title ? `title: ${artifact.title}` : "",
510
510
+
artifact.body ? `body: ${artifact.body}` : "",
511
511
+
artifact.url ? `url: ${artifact.url}` : "",
512
512
+
...(artifact.media || []).map((item) => item.alt ? `media: ${item.alt}` : ""),
513
513
+
...(artifact.refs || []).map((item) => item.uri ? `reference: ${item.uri}` : "")
514
514
+
].filter(Boolean);
515
515
+
return lines.join("\n").slice(0, MAX_TEXT_LEN);
516
516
+
}
517
517
+
518
518
+
function walkArtifact(value, path, out) {
519
519
+
if (isStrongRef(value)) {
520
520
+
out.refs.push({ uri: value.uri, cid: value.cid, path });
521
521
+
return;
522
522
+
}
523
523
+
524
524
+
if (Array.isArray(value)) {
525
525
+
if (value.every((item) => typeof item === "string" || typeof item === "number")) {
526
526
+
const kept = value.map(String).filter((item) => isSemanticString(path, item) || looksLikeUrl(item));
527
527
+
if (kept.length) addArtifactValue(path, kept.join(", "), out);
528
528
+
return;
529
529
+
}
530
530
+
for (const item of value) walkArtifact(item, path, out);
531
531
+
return;
532
532
+
}
533
533
+
534
534
+
if (value && typeof value === "object") {
535
535
+
const blobCid = blobCidFrom(value);
536
536
+
if (blobCid) {
537
537
+
const mimeType = typeof value.mimeType === "string" ? value.mimeType : "";
538
538
+
if (mimeType.startsWith("image/")) out.media.push({ cid: blobCid, path, alt: "" });
539
539
+
return;
540
540
+
}
541
541
+
for (const [key, child] of Object.entries(value)) {
542
542
+
if (isNoiseKey(key)) continue;
543
543
+
const nextPath = path ? `${path}.${key}` : key;
544
544
+
walkArtifact(child, nextPath, out);
545
545
+
}
546
546
+
return;
547
547
+
}
548
548
+
549
549
+
if (typeof value === "string" || typeof value === "number") {
550
550
+
addArtifactValue(path, value, out);
551
551
+
}
552
552
+
}
553
553
+
554
554
+
function addArtifactValue(path, raw, out) {
555
555
+
const value = String(raw).trim();
556
556
+
if (!value) return;
557
557
+
if (looksLikeIsoTimestamp(value)) {
558
558
+
out.dates.push({ path, value });
559
559
+
return;
560
560
+
}
561
561
+
if (looksLikeUrl(value)) {
562
562
+
out.urls.push({ path, value });
563
563
+
return;
564
564
+
}
565
565
+
if (typeof raw === "number" && (!isSemanticNumberPath(path) || raw === 0)) return;
566
566
+
if (isSemanticString(path, value) || typeof raw === "number") out.fields.push({ path, value });
567
567
+
}
568
568
+
569
569
+
function pickField(fields, scoreFn) {
570
570
+
return fields
571
571
+
.map((field, index) => ({ field, score: scoreFn(field) - index * 0.001 }))
572
572
+
.filter((item) => item.score > 0)
573
573
+
.sort((a, b) => b.score - a.score)[0]?.field || null;
574
574
+
}
575
575
+
576
576
+
function titleScore(field) {
577
577
+
const leaf = pathLeaf(field.path);
578
578
+
let score = 0;
579
579
+
if (["title", "name", "trackName", "eventName", "displayName", "label"].includes(leaf)) score += 10;
580
580
+
if (["artistName", "author", "creator", "service", "musicServiceBaseDomain"].includes(leaf)) score += 4;
581
581
+
if (field.value.length > 160) score -= 5;
582
582
+
if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(field.value)) score -= 2;
583
583
+
return score;
584
584
+
}
585
585
+
586
586
+
function bodyScore(field, titleValue = "") {
587
587
+
const leaf = pathLeaf(field.path);
588
588
+
let score = 0;
589
589
+
if (["text", "body", "description", "content", "summary", "note", "comment", "message", "caption", "alt"].includes(leaf)) score += 10;
590
590
+
if (field.path.endsWith(".text")) score += 3;
591
591
+
if (["artistName", "name"].includes(leaf)) score += 4;
592
592
+
if (field.value === titleValue) score -= 20;
593
593
+
if (field.value.length > 1200) score -= 4;
594
594
+
return score;
595
595
+
}
596
596
+
597
597
+
function pickUrl(urls) {
598
598
+
return urls
599
599
+
.map((url, index) => ({ url, score: urlScore(url) - index * 0.001 }))
600
600
+
.sort((a, b) => b.score - a.score)[0]?.url || null;
601
601
+
}
602
602
+
603
603
+
function urlScore(url) {
604
604
+
const leaf = pathLeaf(url.path);
605
605
+
let score = 1;
606
606
+
if (["url", "uri", "href", "link", "originUrl", "externalUri"].includes(leaf)) score += 5;
607
607
+
if (/avatar|icon|image|thumb/i.test(url.path)) score -= 3;
608
608
+
return score;
609
609
+
}
610
610
+
611
611
+
function pickDate(dates) {
612
612
+
return dates
613
613
+
.map((date, index) => ({ date, score: dateScore(date) - index * 0.001 }))
614
614
+
.sort((a, b) => b.score - a.score)[0]?.date.value || "";
615
615
+
}
616
616
+
617
617
+
function dateScore(date) {
618
618
+
const leaf = pathLeaf(date.path);
619
619
+
let score = 1;
620
620
+
if (["createdAt", "playedTime", "startTime", "publishedAt", "updatedAt"].includes(leaf)) score += 8;
621
621
+
if (["indexedAt"].includes(leaf)) score -= 4;
622
622
+
return score;
623
623
+
}
624
624
+
625
625
+
function collectionKind(collection) {
626
626
+
return humanWords(String(collection || "").split(".").pop() || "item");
627
627
+
}
628
628
+
629
629
+
function appendTextLine(lines, path, raw) {
630
630
+
const value = String(raw).trim();
631
631
+
if (!value) return;
632
632
+
lines.push(path ? `${path}: ${value}` : value);
633
633
+
}
634
634
+
635
635
+
function isNoiseKey(key) {
636
636
+
return key.startsWith("$") || ["cid", "rev", "sig", "prev", "version", "ref"].includes(key);
637
637
+
}
638
638
+
639
639
+
function pathLeaf(path) {
640
640
+
return String(path || "").split(".").pop() || "";
641
641
+
}
642
642
+
643
643
+
function looksLikeUrl(value) {
644
644
+
return /^https?:\/\//i.test(String(value).trim());
645
645
+
}
646
646
+
647
647
+
function blobCidFrom(value) {
648
648
+
return value?.ref?.$link || value?.ref?.["$link"] || value?.["$link"] || "";
649
649
+
}
650
650
+
651
651
+
function isIdentifier(value) {
652
652
+
const s = String(value).trim();
653
653
+
if (!s) return true;
654
654
+
if (s.startsWith("did:plc:") || s.startsWith("did:web:") || s.startsWith("at://")) return true;
655
655
+
if (s.startsWith("bafy") || s.startsWith("bafk")) return true;
656
656
+
if (/^[a-z2-7]{13}$/.test(s)) return true;
657
657
+
if (/^[0-9a-f]{32,}$/i.test(s)) return true;
658
658
+
return false;
659
659
+
}
660
660
+
661
661
+
function isSemanticString(path, value) {
662
662
+
const s = String(value).trim();
663
663
+
if (s.length < 2) return false;
664
664
+
if (isIdentifier(s) || looksLikeIsoTimestamp(s)) return false;
665
665
+
if (/^https?:\/\//i.test(s)) return false;
666
666
+
if (/^[a-z0-9._:-]+$/i.test(s) && s.length > 24) return false;
667
667
+
668
668
+
const leaf = path.split(".").pop() || "";
669
669
+
if (["uri", "url", "did", "rkey", "subject", "createdAt", "updatedAt", "indexedAt"].includes(leaf)) return false;
670
670
+
671
671
+
return /[\p{L}\p{N}]/u.test(s);
672
672
+
}
673
673
+
674
674
+
function isSemanticNumberPath(path) {
675
675
+
const leaf = path.split(".").pop() || "";
676
676
+
return ["rating", "score", "count", "position", "duration", "year"].includes(leaf);
677
677
+
}
678
678
+
679
679
+
function looksLikeIsoTimestamp(value) {
680
680
+
return /^\d{4}-\d{2}-\d{2}[T ]/.test(value);
681
681
+
}
682
682
+
683
683
+
function isStrongRef(value) {
684
684
+
return !!(
685
685
+
value &&
686
686
+
typeof value === "object" &&
687
687
+
!Array.isArray(value) &&
688
688
+
typeof value.uri === "string" &&
689
689
+
typeof value.cid === "string" &&
690
690
+
value.uri.startsWith("at://")
691
691
+
);
692
692
+
}
693
693
+
694
694
+
function findIsoDate(value) {
695
695
+
if (!value || typeof value !== "object") return "";
696
696
+
if (typeof value.createdAt === "string") return value.createdAt;
697
697
+
for (const child of Object.values(value)) {
698
698
+
if (child && typeof child === "object") {
699
699
+
const found = findIsoDate(child);
700
700
+
if (found) return found;
701
701
+
}
702
702
+
}
703
703
+
return "";
704
704
+
}
705
705
+
706
706
+
function readableResult(row) {
707
707
+
const collection = row.collection || collectionFromUri(row.uri || "");
708
708
+
const artifact = rowToArtifact(collection, row);
709
709
+
return {
710
710
+
uri: row.uri || "",
711
711
+
collection,
712
712
+
date: humanDate(artifact.date || row.createdAt),
713
713
+
body: artifact.body || artifact.title || cleanRecordText(row.text || ""),
714
714
+
artifact,
715
715
+
distance: typeof row.$dist === "number" ? row.$dist : row.dist
716
716
+
};
717
717
+
}
718
718
+
719
719
+
function readableResults(rows, topK) {
720
720
+
const byArtifact = new Map();
721
721
+
for (const result of (Array.isArray(rows) ? rows : []).map(readableResult)) {
722
722
+
const key = artifactKey(result);
723
723
+
const current = byArtifact.get(key);
724
724
+
if (current) {
725
725
+
current.count += 1;
726
726
+
continue;
727
727
+
}
728
728
+
byArtifact.set(key, { ...result, count: 1 });
729
729
+
}
730
730
+
return Array.from(byArtifact.values()).slice(0, topK);
731
731
+
}
732
732
+
733
733
+
function artifactKey(result) {
734
734
+
const artifact = result.artifact || {};
735
735
+
return [
736
736
+
result.collection || "",
737
737
+
artifact.kind || "",
738
738
+
artifact.title || "",
739
739
+
artifact.body || result.body || "",
740
740
+
artifact.url || ""
741
741
+
].join("\u0000").toLowerCase();
742
742
+
}
743
743
+
744
744
+
function rowToArtifact(collection, row) {
745
745
+
const hasArtifact = row.artifactTitle || row.artifactBody || row.artifactUrl || row.artifactKind;
746
746
+
if (hasArtifact) {
747
747
+
return {
748
748
+
kind: row.artifactKind || collectionKind(collection),
749
749
+
title: row.artifactTitle || "",
750
750
+
body: row.artifactBody || "",
751
751
+
url: row.artifactUrl || "",
752
752
+
media: parseJsonArray(row.artifactMedia),
753
753
+
refs: parseJsonArray(row.artifactRefs),
754
754
+
date: row.artifactDate || row.createdAt || "",
755
755
+
confidence: row.artifactConfidence || "low"
756
756
+
};
757
757
+
}
758
758
+
759
759
+
const text = cleanRecordText(row.text || "");
760
760
+
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
761
761
+
const title = lines.length > 1 && lines[0].length <= 90 ? lines[0] : "";
762
762
+
const body = title ? lines.slice(1).join("\n") : text;
763
763
+
return {
764
764
+
kind: collectionKind(collection),
765
765
+
title,
766
766
+
body,
767
767
+
url: firstUrl(text),
768
768
+
media: [],
769
769
+
refs: [],
770
770
+
date: row.createdAt || "",
771
771
+
confidence: "low"
772
772
+
};
773
773
+
}
774
774
+
775
775
+
function parseJsonArray(value) {
776
776
+
if (!value) return [];
777
777
+
if (Array.isArray(value)) return value;
778
778
+
try {
779
779
+
const parsed = JSON.parse(value);
780
780
+
return Array.isArray(parsed) ? parsed : [];
781
781
+
} catch {
782
782
+
return [];
783
783
+
}
784
784
+
}
785
785
+
786
786
+
function firstUrl(value) {
787
787
+
return String(value || "").match(/https?:\/\/[^\s)]+/)?.[0] || "";
788
788
+
}
789
789
+
790
790
+
function collectionFromUri(uri) {
791
791
+
const parts = String(uri || "").split("/");
792
792
+
return parts.length >= 4 ? parts[3] : "";
793
793
+
}
794
794
+
795
795
+
function cleanRecordText(text) {
796
796
+
const lines = text.split("\n");
797
797
+
const textLine = lines.find((line) => line.startsWith("text: "));
798
798
+
if (textLine) {
799
799
+
const start = lines.indexOf(textLine);
800
800
+
const body = [];
801
801
+
for (const line of lines.slice(start)) {
802
802
+
if (body.length > 0 && /^[a-zA-Z0-9_.]+: /.test(line)) break;
803
803
+
body.push(line.replace(/^text: /, ""));
804
804
+
}
805
805
+
return body.join("\n").trim();
806
806
+
}
807
807
+
808
808
+
return lines
809
809
+
.filter((line) => !line.startsWith("collection: "))
810
810
+
.map((line) => line.replace(/^[a-zA-Z0-9_.]+: /, ""))
811
811
+
.filter((line) => line.trim())
812
812
+
.join("\n")
813
813
+
.trim()
814
814
+
.slice(0, 900);
815
815
+
}
816
816
+
817
817
+
function humanWords(value) {
818
818
+
return String(value || "item")
819
819
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
820
820
+
.replace(/[-_]+/g, " ")
821
821
+
.toLowerCase();
822
822
+
}
823
823
+
824
824
+
function humanDate(value) {
825
825
+
if (!value) return "";
826
826
+
const date = new Date(value);
827
827
+
if (Number.isNaN(date.valueOf())) return "";
828
828
+
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
829
829
+
}
830
830
+
831
831
+
function json(body, status = 200) {
832
832
+
return new Response(JSON.stringify(body), {
833
833
+
status,
834
834
+
headers: {
835
835
+
"content-type": "application/json; charset=utf-8",
836
836
+
"cache-control": "no-store"
837
837
+
}
838
838
+
});
839
839
+
}
840
840
+
841
841
+
function clamp(value, min, max) {
842
842
+
if (!Number.isFinite(value)) return min;
843
843
+
return Math.min(max, Math.max(min, Math.round(value)));
844
844
+
}
845
845
+
846
846
+
function renderIndex() {
847
847
+
return `<!doctype html>
848
848
+
<html lang="en">
849
849
+
<head>
850
850
+
<meta charset="utf-8">
851
851
+
<meta name="viewport" content="width=device-width, initial-scale=1">
852
852
+
<title>Pensieve</title>
853
853
+
<style>
854
854
+
:root {
855
855
+
color-scheme: dark;
856
856
+
--bg: #120d08;
857
857
+
--paper: #d9c28f;
858
858
+
--paper-2: #c4a86e;
859
859
+
--ink: #2a1710;
860
860
+
--ink-soft: #5f4229;
861
861
+
--ink-faint: #876b45;
862
862
+
--line: #7c5f37;
863
863
+
--line-strong: #4c2d1c;
864
864
+
--text: #2a1710;
865
865
+
--muted: #5f4229;
866
866
+
--dim: #876b45;
867
867
+
--water: #dff8f5;
868
868
+
--water-2: #7aa0a2;
869
869
+
--brass: #9b6b22;
870
870
+
--candle: #ffd985;
871
871
+
--danger: #8d2e20;
872
872
+
--shadow: rgba(0, 0, 0, .38);
873
873
+
--sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
874
874
+
--serif: Iowan Old Style, Palatino, Palatino Linotype, Book Antiqua, Georgia, serif;
875
875
+
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
876
876
+
font-family: var(--sans);
877
877
+
}
878
878
+
879
879
+
* { box-sizing: border-box; }
880
880
+
881
881
+
body {
882
882
+
margin: 0;
883
883
+
min-height: 100vh;
884
884
+
background:
885
885
+
radial-gradient(ellipse at 16% 0%, rgba(255, 217, 133, .26), transparent 34%),
886
886
+
radial-gradient(ellipse at 76% 6%, rgba(223, 248, 245, .12), transparent 42%),
887
887
+
linear-gradient(90deg, rgba(255,255,255,.025) 1px, transparent 1px),
888
888
+
linear-gradient(rgba(255,255,255,.02) 1px, transparent 1px),
889
889
+
linear-gradient(180deg, rgba(67, 38, 20, .64), transparent 300px),
890
890
+
var(--bg);
891
891
+
background-size: auto, auto, 96px 96px, 96px 48px, auto, auto;
892
892
+
color: var(--text);
893
893
+
}
894
894
+
895
895
+
.page {
896
896
+
width: min(930px, calc(100% - 28px));
897
897
+
margin: 0 auto;
898
898
+
padding: 24px 0 56px;
899
899
+
}
900
900
+
901
901
+
header {
902
902
+
display: flex;
903
903
+
justify-content: space-between;
904
904
+
align-items: center;
905
905
+
gap: 16px;
906
906
+
padding-bottom: 24px;
907
907
+
}
908
908
+
909
909
+
.wordmark {
910
910
+
display: inline-flex;
911
911
+
align-items: center;
912
912
+
gap: 12px;
913
913
+
font-family: var(--serif);
914
914
+
font-weight: 600;
915
915
+
font-size: 22px;
916
916
+
color: #f5ead2;
917
917
+
letter-spacing: 0;
918
918
+
text-shadow: 0 1px 0 rgba(0,0,0,.36);
919
919
+
}
920
920
+
921
921
+
.wordmark::before {
922
922
+
content: "";
923
923
+
width: 34px;
924
924
+
height: 34px;
925
925
+
border-radius: 50%;
926
926
+
border: 1px solid rgba(217, 194, 143, .86);
927
927
+
background:
928
928
+
repeating-radial-gradient(circle, transparent 0 5px, rgba(183, 239, 231, .42) 6px 7px),
929
929
+
radial-gradient(circle at 50% 54%, rgba(183, 239, 231, .18), rgba(8, 7, 6, .34) 62%);
930
930
+
box-shadow: inset 0 0 18px rgba(183, 239, 231, .16), 0 0 0 4px rgba(214, 181, 99, .08);
931
931
+
}
932
932
+
933
933
+
.note {
934
934
+
color: #d9caa9;
935
935
+
font-family: var(--mono);
936
936
+
font-size: 12px;
937
937
+
}
938
938
+
939
939
+
.memory-basin {
940
940
+
position: relative;
941
941
+
overflow: visible;
942
942
+
border: 1px solid rgba(76, 45, 28, .72);
943
943
+
border-radius: 14px 58px 22px 46px;
944
944
+
padding: 34px 26px 26px;
945
945
+
background:
946
946
+
radial-gradient(ellipse at 17% 13%, rgba(255, 246, 210, .6), transparent 30%),
947
947
+
radial-gradient(ellipse at 83% 23%, rgba(88, 48, 21, .22), transparent 28%),
948
948
+
linear-gradient(115deg, rgba(72, 38, 19, .1), transparent 42%),
949
949
+
repeating-linear-gradient(2deg, rgba(76, 45, 28, .065) 0 1px, transparent 1px 7px),
950
950
+
var(--paper);
951
951
+
box-shadow:
952
952
+
inset 0 0 0 1px rgba(255, 255, 255, .18),
953
953
+
inset 0 -24px 70px rgba(89, 47, 24, .16),
954
954
+
0 30px 90px rgba(0,0,0,.42);
955
955
+
}
956
956
+
957
957
+
.memory-basin::before {
958
958
+
content: "";
959
959
+
position: absolute;
960
960
+
inset: 16px 28px auto;
961
961
+
height: 120px;
962
962
+
border-radius: 52% 48% 46% 54%;
963
963
+
border: 1px solid rgba(76, 45, 28, .2);
964
964
+
background:
965
965
+
repeating-radial-gradient(ellipse at 50% 50%, transparent 0 17px, rgba(42, 23, 16, .13) 18px 19px),
966
966
+
radial-gradient(ellipse at 50% 48%, rgba(223, 248, 245, .3), rgba(124, 160, 162, .16) 44%, transparent 70%);
967
967
+
pointer-events: none;
968
968
+
opacity: .74;
969
969
+
mask-image: linear-gradient(180deg, black, transparent 72%);
970
970
+
}
971
971
+
972
972
+
.memory-basin::after {
973
973
+
content: "";
974
974
+
position: absolute;
975
975
+
right: 34px;
976
976
+
top: 28px;
977
977
+
width: 148px;
978
978
+
height: 86px;
979
979
+
border-radius: 50%;
980
980
+
border: 1px solid rgba(42, 23, 16, .52);
981
981
+
background:
982
982
+
repeating-radial-gradient(ellipse at 50% 50%, transparent 0 9px, rgba(42, 23, 16, .22) 10px 11px),
983
983
+
radial-gradient(ellipse at 50% 50%, rgba(237, 252, 249, .58), rgba(122, 160, 162, .24) 48%, rgba(42,23,16,.16) 70%);
984
984
+
box-shadow:
985
985
+
inset 0 0 26px rgba(42,23,16,.34),
986
986
+
0 0 0 5px rgba(245,234,210,.2),
987
987
+
0 18px 40px rgba(42,23,16,.22);
988
988
+
pointer-events: none;
989
989
+
}
990
990
+
991
991
+
.sconces {
992
992
+
position: absolute;
993
993
+
inset: 92px 54px auto;
994
994
+
display: flex;
995
995
+
justify-content: space-between;
996
996
+
pointer-events: none;
997
997
+
z-index: 0;
998
998
+
}
999
999
+
1000
1000
+
.sconce {
1001
1001
+
width: 18px;
1002
1002
+
height: 9px;
1003
1003
+
border-radius: 50%;
1004
1004
+
background: rgba(42, 23, 16, .42);
1005
1005
+
box-shadow: 0 0 0 1px rgba(42, 23, 16, .18);
1006
1006
+
opacity: .6;
1007
1007
+
}
1008
1008
+
1009
1009
+
.sconce::after {
1010
1010
+
content: "";
1011
1011
+
display: block;
1012
1012
+
width: 8px;
1013
1013
+
height: 8px;
1014
1014
+
margin: -8px auto 0;
1015
1015
+
border-radius: 999px 999px 999px 0;
1016
1016
+
transform: rotate(-35deg);
1017
1017
+
background: var(--candle);
1018
1018
+
box-shadow: 0 0 18px rgba(255, 217, 133, .74), 0 0 44px rgba(255, 217, 133, .34);
1019
1019
+
}
1020
1020
+
1021
1021
+
.hero {
1022
1022
+
display: grid;
1023
1023
+
grid-template-columns: minmax(0, 1fr) 190px;
1024
1024
+
gap: 24px;
1025
1025
+
align-items: center;
1026
1026
+
margin-bottom: 24px;
1027
1027
+
position: relative;
1028
1028
+
z-index: 1;
1029
1029
+
}
1030
1030
+
1031
1031
+
.kicker {
1032
1032
+
margin: 0 0 8px;
1033
1033
+
color: var(--brass);
1034
1034
+
font-family: var(--mono);
1035
1035
+
font-size: 12px;
1036
1036
+
font-weight: 700;
1037
1037
+
}
1038
1038
+
1039
1039
+
h1 {
1040
1040
+
margin: 0;
1041
1041
+
max-width: 520px;
1042
1042
+
font-family: var(--serif);
1043
1043
+
font-size: clamp(36px, 6vw, 58px);
1044
1044
+
line-height: .98;
1045
1045
+
font-weight: 560;
1046
1046
+
color: var(--ink);
1047
1047
+
letter-spacing: 0;
1048
1048
+
}
1049
1049
+
1050
1050
+
.intro {
1051
1051
+
max-width: 520px;
1052
1052
+
margin: 12px 0 0;
1053
1053
+
color: var(--muted);
1054
1054
+
font-size: 16px;
1055
1055
+
line-height: 1.5;
1056
1056
+
}
1057
1057
+
1058
1058
+
.basin {
1059
1059
+
display: none;
1060
1060
+
}
1061
1061
+
1062
1062
+
.map-trail {
1063
1063
+
position: absolute;
1064
1064
+
left: 38px;
1065
1065
+
right: 38px;
1066
1066
+
top: 224px;
1067
1067
+
height: 72px;
1068
1068
+
pointer-events: none;
1069
1069
+
opacity: .68;
1070
1070
+
background:
1071
1071
+
radial-gradient(ellipse at 8% 55%, rgba(42,23,16,.5) 0 4px, transparent 5px),
1072
1072
+
radial-gradient(ellipse at 14% 44%, rgba(42,23,16,.5) 0 3px, transparent 4px),
1073
1073
+
radial-gradient(ellipse at 28% 66%, rgba(42,23,16,.42) 0 4px, transparent 5px),
1074
1074
+
radial-gradient(ellipse at 36% 48%, rgba(42,23,16,.42) 0 3px, transparent 4px),
1075
1075
+
radial-gradient(ellipse at 58% 58%, rgba(42,23,16,.4) 0 4px, transparent 5px),
1076
1076
+
radial-gradient(ellipse at 66% 40%, rgba(42,23,16,.4) 0 3px, transparent 4px),
1077
1077
+
radial-gradient(ellipse at 84% 60%, rgba(42,23,16,.38) 0 4px, transparent 5px),
1078
1078
+
radial-gradient(ellipse at 91% 44%, rgba(42,23,16,.38) 0 3px, transparent 4px);
1079
1079
+
transform: rotate(-2deg);
1080
1080
+
}
1081
1081
+
1082
1082
+
.map-trail::before {
1083
1083
+
content: "";
1084
1084
+
position: absolute;
1085
1085
+
left: 5%;
1086
1086
+
right: 6%;
1087
1087
+
top: 32px;
1088
1088
+
border-top: 1px dashed rgba(42, 23, 16, .24);
1089
1089
+
transform: rotate(1deg);
1090
1090
+
}
1091
1091
+
1092
1092
+
.search-panel {
1093
1093
+
position: relative;
1094
1094
+
z-index: 20;
1095
1095
+
overflow: visible;
1096
1096
+
background:
1097
1097
+
linear-gradient(180deg, rgba(255,255,255,.22), transparent),
1098
1098
+
repeating-linear-gradient(1deg, rgba(42,23,16,.045) 0 1px, transparent 1px 9px),
1099
1099
+
rgba(235, 216, 163, .54);
1100
1100
+
border: 1px solid rgba(76, 45, 28, .44);
1101
1101
+
border-radius: 7px;
1102
1102
+
padding: 14px;
1103
1103
+
box-shadow:
1104
1104
+
inset 0 1px 0 rgba(255,255,255,.22),
1105
1105
+
0 10px 26px rgba(42,23,16,.14);
1106
1106
+
}
1107
1107
+
1108
1108
+
.panel-title {
1109
1109
+
display: flex;
1110
1110
+
justify-content: space-between;
1111
1111
+
gap: 14px;
1112
1112
+
margin: 0 0 14px;
1113
1113
+
color: var(--ink-soft);
1114
1114
+
font-family: var(--mono);
1115
1115
+
font-size: 12px;
1116
1116
+
}
1117
1117
+
1118
1118
+
.panel-title span:last-child {
1119
1119
+
color: var(--ink-faint);
1120
1120
+
}
1121
1121
+
1122
1122
+
.steps {
1123
1123
+
display: grid;
1124
1124
+
grid-template-columns: 1fr;
1125
1125
+
gap: 12px;
1126
1126
+
align-items: end;
1127
1127
+
}
1128
1128
+
1129
1129
+
.actions {
1130
1130
+
display: block;
1131
1131
+
}
1132
1132
+
1133
1133
+
.actions button {
1134
1134
+
width: 100%;
1135
1135
+
}
1136
1136
+
1137
1137
+
.field {
1138
1138
+
min-width: 0;
1139
1139
+
}
1140
1140
+
1141
1141
+
label {
1142
1142
+
display: block;
1143
1143
+
color: var(--ink-soft);
1144
1144
+
font-family: var(--mono);
1145
1145
+
font-size: 11px;
1146
1146
+
margin-bottom: 7px;
1147
1147
+
}
1148
1148
+
1149
1149
+
input {
1150
1150
+
width: 100%;
1151
1151
+
height: 48px;
1152
1152
+
border: 1px solid var(--line);
1153
1153
+
border-radius: 5px;
1154
1154
+
background: rgba(255, 244, 205, .42);
1155
1155
+
color: var(--ink);
1156
1156
+
font-family: var(--serif);
1157
1157
+
font-size: 18px;
1158
1158
+
font-weight: 540;
1159
1159
+
padding: 0 14px;
1160
1160
+
outline: none;
1161
1161
+
}
1162
1162
+
1163
1163
+
input::placeholder { color: rgba(95, 66, 41, .68); }
1164
1164
+
1165
1165
+
input:focus {
1166
1166
+
border-color: rgba(42, 23, 16, .76);
1167
1167
+
box-shadow: 0 0 0 3px rgba(42, 23, 16, .12);
1168
1168
+
}
1169
1169
+
1170
1170
+
.person-wrap {
1171
1171
+
position: relative;
1172
1172
+
z-index: 30;
1173
1173
+
}
1174
1174
+
1175
1175
+
.person {
1176
1176
+
display: flex;
1177
1177
+
align-items: center;
1178
1178
+
gap: 10px;
1179
1179
+
height: 48px;
1180
1180
+
border: 1px solid var(--line);
1181
1181
+
border-radius: 5px;
1182
1182
+
padding: 0 12px;
1183
1183
+
background: rgba(255, 244, 205, .42);
1184
1184
+
}
1185
1185
+
1186
1186
+
.person:focus-within {
1187
1187
+
border-color: rgba(42, 23, 16, .76);
1188
1188
+
box-shadow: 0 0 0 3px rgba(42, 23, 16, .12);
1189
1189
+
}
1190
1190
+
1191
1191
+
.selected-avatar, .avatar {
1192
1192
+
width: 28px;
1193
1193
+
height: 28px;
1194
1194
+
border-radius: 50%;
1195
1195
+
background: #24242a;
1196
1196
+
flex: 0 0 auto;
1197
1197
+
object-fit: cover;
1198
1198
+
}
1199
1199
+
1200
1200
+
.selected-avatar {
1201
1201
+
display: none;
1202
1202
+
}
1203
1203
+
1204
1204
+
.person.has-avatar .selected-avatar {
1205
1205
+
display: block;
1206
1206
+
}
1207
1207
+
1208
1208
+
.person.has-avatar {
1209
1209
+
border-color: rgba(42, 23, 16, .72);
1210
1210
+
}
1211
1211
+
1212
1212
+
.person input {
1213
1213
+
min-width: 0;
1214
1214
+
height: 46px;
1215
1215
+
padding: 0;
1216
1216
+
border: 0;
1217
1217
+
border-radius: 0;
1218
1218
+
background: transparent;
1219
1219
+
box-shadow: none;
1220
1220
+
font-family: var(--serif);
1221
1221
+
font-size: 18px;
1222
1222
+
font-weight: 540;
1223
1223
+
line-height: 46px;
1224
1224
+
caret-color: var(--ink);
1225
1225
+
-webkit-appearance: none;
1226
1226
+
appearance: none;
1227
1227
+
}
1228
1228
+
1229
1229
+
.person input:focus {
1230
1230
+
border-color: transparent;
1231
1231
+
box-shadow: none;
1232
1232
+
}
1233
1233
+
1234
1234
+
.menu {
1235
1235
+
position: absolute;
1236
1236
+
z-index: 1000;
1237
1237
+
display: none;
1238
1238
+
left: 0;
1239
1239
+
right: 0;
1240
1240
+
top: calc(100% + 8px);
1241
1241
+
background:
1242
1242
+
linear-gradient(180deg, rgba(255,255,255,.16), transparent),
1243
1243
+
#e0c993;
1244
1244
+
border: 1px solid var(--line-strong);
1245
1245
+
border-radius: 6px;
1246
1246
+
overflow: hidden;
1247
1247
+
box-shadow: 0 18px 48px rgba(0, 0, 0, .44);
1248
1248
+
}
1249
1249
+
1250
1250
+
.menu.open { display: block; }
1251
1251
+
1252
1252
+
.actor {
1253
1253
+
display: flex;
1254
1254
+
gap: 10px;
1255
1255
+
align-items: center;
1256
1256
+
width: 100%;
1257
1257
+
padding: 10px;
1258
1258
+
border: 0;
1259
1259
+
background: #e0c993;
1260
1260
+
color: var(--ink);
1261
1261
+
text-align: left;
1262
1262
+
cursor: pointer;
1263
1263
+
font-family: var(--serif);
1264
1264
+
font-size: 16px;
1265
1265
+
}
1266
1266
+
1267
1267
+
.actor:hover, .actor.active { background: #cfb373; }
1268
1268
+
1269
1269
+
.actor strong, .actor span {
1270
1270
+
display: block;
1271
1271
+
overflow: hidden;
1272
1272
+
text-overflow: ellipsis;
1273
1273
+
white-space: nowrap;
1274
1274
+
}
1275
1275
+
1276
1276
+
.actor span {
1277
1277
+
color: var(--ink-soft);
1278
1278
+
font-size: 13px;
1279
1279
+
}
1280
1280
+
1281
1281
+
button {
1282
1282
+
height: 48px;
1283
1283
+
border: 0;
1284
1284
+
border-radius: 5px;
1285
1285
+
background: var(--ink);
1286
1286
+
color: #f5ead2;
1287
1287
+
font-family: var(--serif);
1288
1288
+
font-size: 18px;
1289
1289
+
font-weight: 700;
1290
1290
+
cursor: pointer;
1291
1291
+
}
1292
1292
+
1293
1293
+
button:hover { background: #3a2116; }
1294
1294
+
button:disabled { opacity: .64; cursor: wait; }
1295
1295
+
1296
1296
+
button.wordmark {
1297
1297
+
height: auto;
1298
1298
+
padding: 0;
1299
1299
+
border: 0;
1300
1300
+
background: transparent;
1301
1301
+
color: #f5ead2;
1302
1302
+
cursor: pointer;
1303
1303
+
}
1304
1304
+
1305
1305
+
button.wordmark:hover,
1306
1306
+
button.wordmark:focus {
1307
1307
+
background: transparent;
1308
1308
+
color: var(--water);
1309
1309
+
outline: none;
1310
1310
+
}
1311
1311
+
1312
1312
+
.help {
1313
1313
+
margin: 13px 2px 0;
1314
1314
+
color: var(--ink-faint);
1315
1315
+
font-size: 13px;
1316
1316
+
line-height: 1.45;
1317
1317
+
}
1318
1318
+
1319
1319
+
.progress {
1320
1320
+
display: none;
1321
1321
+
margin-top: 12px;
1322
1322
+
padding: 12px;
1323
1323
+
border: 1px solid rgba(76, 45, 28, .44);
1324
1324
+
border-radius: 7px;
1325
1325
+
background: rgba(255, 244, 205, .28);
1326
1326
+
}
1327
1327
+
1328
1328
+
.progress.active {
1329
1329
+
display: block;
1330
1330
+
}
1331
1331
+
1332
1332
+
.progress-bar {
1333
1333
+
height: 4px;
1334
1334
+
margin-bottom: 10px;
1335
1335
+
overflow: hidden;
1336
1336
+
border-radius: 999px;
1337
1337
+
background: rgba(42, 23, 16, .18);
1338
1338
+
}
1339
1339
+
1340
1340
+
.progress-fill {
1341
1341
+
width: 0%;
1342
1342
+
height: 100%;
1343
1343
+
border-radius: inherit;
1344
1344
+
background: linear-gradient(90deg, #466f70, var(--water));
1345
1345
+
transition: width .25s ease;
1346
1346
+
}
1347
1347
+
1348
1348
+
.progress-meta {
1349
1349
+
display: flex;
1350
1350
+
align-items: baseline;
1351
1351
+
justify-content: space-between;
1352
1352
+
gap: 12px;
1353
1353
+
color: var(--ink-soft);
1354
1354
+
font-family: var(--mono);
1355
1355
+
font-size: 12px;
1356
1356
+
font-variant-numeric: tabular-nums;
1357
1357
+
}
1358
1358
+
1359
1359
+
.progress-detail {
1360
1360
+
margin-top: 6px;
1361
1361
+
color: var(--ink-faint);
1362
1362
+
font-size: 12px;
1363
1363
+
line-height: 1.45;
1364
1364
+
}
1365
1365
+
1366
1366
+
.results {
1367
1367
+
margin-top: 18px;
1368
1368
+
border: 1px solid rgba(76, 45, 28, .46);
1369
1369
+
border-radius: 7px;
1370
1370
+
background:
1371
1371
+
linear-gradient(180deg, rgba(255,255,255,.14), transparent),
1372
1372
+
rgba(235, 216, 163, .42);
1373
1373
+
font-family: var(--serif);
1374
1374
+
overflow: hidden;
1375
1375
+
}
1376
1376
+
1377
1377
+
.results:empty {
1378
1378
+
display: none;
1379
1379
+
}
1380
1380
+
1381
1381
+
.empty {
1382
1382
+
color: var(--ink-soft);
1383
1383
+
padding: 18px 20px;
1384
1384
+
font-family: var(--serif);
1385
1385
+
font-size: 18px;
1386
1386
+
line-height: 1.5;
1387
1387
+
}
1388
1388
+
1389
1389
+
.result {
1390
1390
+
display: grid;
1391
1391
+
grid-template-columns: 44px minmax(0, 1fr);
1392
1392
+
gap: 14px;
1393
1393
+
padding: 16px 18px;
1394
1394
+
border-top: 1px solid rgba(76, 45, 28, .3);
1395
1395
+
cursor: pointer;
1396
1396
+
transition: background .16s ease, box-shadow .16s ease;
1397
1397
+
}
1398
1398
+
1399
1399
+
.result:first-child {
1400
1400
+
border-top: 0;
1401
1401
+
}
1402
1402
+
1403
1403
+
.result:hover,
1404
1404
+
.result:focus {
1405
1405
+
background: rgba(255, 244, 205, .2);
1406
1406
+
box-shadow: inset 3px 0 0 rgba(31, 95, 95, .52);
1407
1407
+
outline: none;
1408
1408
+
}
1409
1409
+
1410
1410
+
.result-head {
1411
1411
+
display: flex;
1412
1412
+
align-items: baseline;
1413
1413
+
justify-content: space-between;
1414
1414
+
gap: 14px;
1415
1415
+
margin-bottom: 6px;
1416
1416
+
font-family: var(--mono);
1417
1417
+
font-size: 11px;
1418
1418
+
line-height: 1.5;
1419
1419
+
}
1420
1420
+
1421
1421
+
.result-stamp {
1422
1422
+
width: 44px;
1423
1423
+
height: 44px;
1424
1424
+
display: grid;
1425
1425
+
place-items: center;
1426
1426
+
border: 1px solid rgba(76, 45, 28, .42);
1427
1427
+
border-radius: 50%;
1428
1428
+
background:
1429
1429
+
radial-gradient(circle at 38% 32%, rgba(255,255,255,.3), transparent 30%),
1430
1430
+
radial-gradient(circle at 50% 50%, rgba(95,66,41,.16), rgba(42,23,16,.08));
1431
1431
+
color: var(--ink);
1432
1432
+
font-family: var(--serif);
1433
1433
+
font-size: 18px;
1434
1434
+
font-weight: 700;
1435
1435
+
overflow: hidden;
1436
1436
+
box-shadow: inset 0 0 0 3px rgba(255,244,205,.28);
1437
1437
+
}
1438
1438
+
1439
1439
+
a.result-stamp {
1440
1440
+
text-decoration: none;
1441
1441
+
flex: 0 0 auto;
1442
1442
+
}
1443
1443
+
1444
1444
+
.result-stamp img {
1445
1445
+
width: 100%;
1446
1446
+
height: 100%;
1447
1447
+
object-fit: cover;
1448
1448
+
display: block;
1449
1449
+
}
1450
1450
+
1451
1451
+
.result-main {
1452
1452
+
min-width: 0;
1453
1453
+
}
1454
1454
+
1455
1455
+
.result-app {
1456
1456
+
display: flex;
1457
1457
+
align-items: baseline;
1458
1458
+
gap: 7px;
1459
1459
+
min-width: 0;
1460
1460
+
overflow: hidden;
1461
1461
+
color: var(--ink);
1462
1462
+
font-family: var(--serif);
1463
1463
+
font-size: 16px;
1464
1464
+
font-weight: 700;
1465
1465
+
white-space: nowrap;
1466
1466
+
text-overflow: ellipsis;
1467
1467
+
}
1468
1468
+
1469
1469
+
.result-app-link {
1470
1470
+
color: var(--ink);
1471
1471
+
font-family: var(--serif);
1472
1472
+
font-size: 16px;
1473
1473
+
font-weight: 700;
1474
1474
+
text-decoration: none;
1475
1475
+
overflow: hidden;
1476
1476
+
text-overflow: ellipsis;
1477
1477
+
}
1478
1478
+
1479
1479
+
.result-app-link:hover {
1480
1480
+
color: #1f5f5f;
1481
1481
+
}
1482
1482
+
1483
1483
+
.result-type {
1484
1484
+
color: var(--ink-faint);
1485
1485
+
font-family: var(--mono);
1486
1486
+
font-size: 11px;
1487
1487
+
font-weight: 500;
1488
1488
+
min-width: 0;
1489
1489
+
overflow: hidden;
1490
1490
+
text-overflow: ellipsis;
1491
1491
+
}
1492
1492
+
1493
1493
+
.result-meta {
1494
1494
+
color: var(--ink-faint);
1495
1495
+
white-space: nowrap;
1496
1496
+
flex: 0 0 auto;
1497
1497
+
}
1498
1498
+
1499
1499
+
.result-count {
1500
1500
+
color: var(--ink-soft);
1501
1501
+
}
1502
1502
+
1503
1503
+
.result time {
1504
1504
+
display: inline;
1505
1505
+
color: var(--ink-soft);
1506
1506
+
}
1507
1507
+
1508
1508
+
.result p {
1509
1509
+
margin: 0;
1510
1510
+
line-height: 1.52;
1511
1511
+
white-space: pre-wrap;
1512
1512
+
font-size: 16px;
1513
1513
+
}
1514
1514
+
1515
1515
+
.artifact-title {
1516
1516
+
margin: 0 0 4px;
1517
1517
+
color: var(--ink);
1518
1518
+
font-family: var(--serif);
1519
1519
+
font-size: 18px;
1520
1520
+
font-weight: 700;
1521
1521
+
line-height: 1.25;
1522
1522
+
overflow-wrap: anywhere;
1523
1523
+
}
1524
1524
+
1525
1525
+
.result-content-link {
1526
1526
+
display: block;
1527
1527
+
color: inherit;
1528
1528
+
text-decoration: none;
1529
1529
+
}
1530
1530
+
1531
1531
+
.result-content-link:hover .artifact-title,
1532
1532
+
.result-content-link:hover .artifact-body {
1533
1533
+
text-decoration: underline;
1534
1534
+
text-decoration-thickness: 1px;
1535
1535
+
text-underline-offset: 3px;
1536
1536
+
}
1537
1537
+
1538
1538
+
.artifact-body {
1539
1539
+
margin: 0;
1540
1540
+
line-height: 1.52;
1541
1541
+
white-space: pre-wrap;
1542
1542
+
overflow-wrap: anywhere;
1543
1543
+
font-size: 16px;
1544
1544
+
}
1545
1545
+
1546
1546
+
.artifact-link {
1547
1547
+
display: inline-block;
1548
1548
+
margin-top: 8px;
1549
1549
+
color: #1f5f5f;
1550
1550
+
font-family: var(--mono);
1551
1551
+
font-size: 12px;
1552
1552
+
text-decoration: none;
1553
1553
+
overflow-wrap: anywhere;
1554
1554
+
}
1555
1555
+
1556
1556
+
.artifact-link:hover { text-decoration: underline; }
1557
1557
+
1558
1558
+
.result a {
1559
1559
+
color: #1f5f5f;
1560
1560
+
font-family: var(--mono);
1561
1561
+
font-size: 12px;
1562
1562
+
font-weight: 600;
1563
1563
+
text-decoration: none;
1564
1564
+
flex: 0 0 auto;
1565
1565
+
}
1566
1566
+
1567
1567
+
.result a:hover { text-decoration: underline; }
1568
1568
+
1569
1569
+
.result .result-content-link {
1570
1570
+
display: block;
1571
1571
+
color: inherit;
1572
1572
+
font-family: var(--serif);
1573
1573
+
font-size: inherit;
1574
1574
+
font-weight: inherit;
1575
1575
+
text-decoration: none;
1576
1576
+
flex: 1 1 auto;
1577
1577
+
}
1578
1578
+
1579
1579
+
.result .result-content-link .artifact-title {
1580
1580
+
color: var(--ink);
1581
1581
+
font-family: var(--serif);
1582
1582
+
font-size: 18px;
1583
1583
+
font-weight: 700;
1584
1584
+
}
1585
1585
+
1586
1586
+
.result .result-content-link .artifact-body {
1587
1587
+
color: var(--ink);
1588
1588
+
font-family: var(--serif);
1589
1589
+
font-size: 16px;
1590
1590
+
font-weight: 400;
1591
1591
+
}
1592
1592
+
1593
1593
+
.result .result-app-link {
1594
1594
+
color: var(--ink);
1595
1595
+
font-family: var(--serif);
1596
1596
+
font-size: 16px;
1597
1597
+
font-weight: 700;
1598
1598
+
}
1599
1599
+
1600
1600
+
.message {
1601
1601
+
display: block;
1602
1602
+
color: var(--muted);
1603
1603
+
line-height: 1.5;
1604
1604
+
}
1605
1605
+
1606
1606
+
.error {
1607
1607
+
display: block;
1608
1608
+
padding: 18px;
1609
1609
+
color: var(--danger);
1610
1610
+
}
1611
1611
+
1612
1612
+
@media (min-width: 720px) {
1613
1613
+
.steps {
1614
1614
+
grid-template-columns: minmax(180px, .72fr) minmax(0, 1.28fr);
1615
1615
+
}
1616
1616
+
1617
1617
+
.actions {
1618
1618
+
grid-column: 1 / -1;
1619
1619
+
}
1620
1620
+
}
1621
1621
+
1622
1622
+
@media (max-width: 700px) {
1623
1623
+
.page { width: min(100% - 20px, 880px); padding-top: 18px; }
1624
1624
+
header { padding-bottom: 22px; }
1625
1625
+
.note { display: none; }
1626
1626
+
.memory-basin { padding: 18px; border-radius: 22px 22px 16px 16px; }
1627
1627
+
.memory-basin::before { inset: 12px 18px auto; height: 64px; }
1628
1628
+
.memory-basin::after {
1629
1629
+
top: 18px;
1630
1630
+
right: 22px;
1631
1631
+
width: 92px;
1632
1632
+
height: 54px;
1633
1633
+
opacity: .72;
1634
1634
+
}
1635
1635
+
.map-trail {
1636
1636
+
left: 24px;
1637
1637
+
right: 24px;
1638
1638
+
top: 286px;
1639
1639
+
opacity: .48;
1640
1640
+
}
1641
1641
+
.hero { display: block; margin-bottom: 18px; }
1642
1642
+
.basin { display: none; }
1643
1643
+
h1 { font-size: 40px; }
1644
1644
+
.intro { font-size: 15px; }
1645
1645
+
.result-head {
1646
1646
+
display: block;
1647
1647
+
}
1648
1648
+
.result {
1649
1649
+
grid-template-columns: 38px minmax(0, 1fr);
1650
1650
+
gap: 11px;
1651
1651
+
padding: 15px 14px;
1652
1652
+
}
1653
1653
+
.result-stamp {
1654
1654
+
width: 38px;
1655
1655
+
height: 38px;
1656
1656
+
font-size: 16px;
1657
1657
+
}
1658
1658
+
.result a {
1659
1659
+
display: inline-block;
1660
1660
+
margin-top: 4px;
1661
1661
+
}
1662
1662
+
}
1663
1663
+
</style>
1664
1664
+
</head>
1665
1665
+
<body>
1666
1666
+
<div class="page">
1667
1667
+
<header>
1668
1668
+
<button id="reset" class="wordmark" type="button">Pensieve</button>
1669
1669
+
<div class="note">public memory search</div>
1670
1670
+
</header>
1671
1671
+
1672
1672
+
<main>
1673
1673
+
<section class="memory-basin">
1674
1674
+
<div class="sconces" aria-hidden="true"><span class="sconce"></span><span class="sconce"></span></div>
1675
1675
+
<div class="hero">
1676
1676
+
<div>
1677
1677
+
<p class="kicker">public posts and notes</p>
1678
1678
+
<h1>Find the thing you half remember.</h1>
1679
1679
+
<p class="intro">Enter someone’s handle and describe the thing you are trying to find.</p>
1680
1680
+
</div>
1681
1681
+
<div class="basin" aria-hidden="true"></div>
1682
1682
+
</div>
1683
1683
+
<div class="map-trail" aria-hidden="true"></div>
1684
1684
+
1685
1685
+
<div class="search-panel" aria-label="Search memories">
1686
1686
+
<div class="panel-title"><span>Pensieve</span><span>search from a memory</span></div>
1687
1687
+
<form id="search">
1688
1688
+
<div class="steps">
1689
1689
+
<div class="field">
1690
1690
+
<label for="actor-input">whose history?</label>
1691
1691
+
<div class="person-wrap">
1692
1692
+
<div id="actor-field" class="person">
1693
1693
+
<img id="actor-avatar" class="selected-avatar" alt="">
1694
1694
+
<input id="actor-input" placeholder="name or handle" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
1695
1695
+
</div>
1696
1696
+
<div id="actor-menu" class="menu" role="listbox"></div>
1697
1697
+
</div>
1698
1698
+
</div>
1699
1699
+
1700
1700
+
<div class="field">
1701
1701
+
<label for="query">what are you looking for?</label>
1702
1702
+
<input id="query" name="q" placeholder="the part you remember..." autocomplete="off" spellcheck="true">
1703
1703
+
</div>
1704
1704
+
1705
1705
+
<div class="actions">
1706
1706
+
<button id="submit" name="action" value="search" type="submit">Search</button>
1707
1707
+
</div>
1708
1708
+
</div>
1709
1709
+
</form>
1710
1710
+
1711
1711
+
<p class="help">Press Enter to search. The first search for someone may take a moment while Pensieve reads what they have shared publicly.</p>
1712
1712
+
<div id="progress" class="progress" aria-live="polite">
1713
1713
+
<div class="progress-bar"><div id="progress-fill" class="progress-fill"></div></div>
1714
1714
+
<div class="progress-meta">
1715
1715
+
<span id="progress-label">starting</span>
1716
1716
+
<span id="progress-count"></span>
1717
1717
+
</div>
1718
1718
+
<div id="progress-detail" class="progress-detail">Looking through public posts and notes. This can take a moment the first time.</div>
1719
1719
+
</div>
1720
1720
+
</div>
1721
1721
+
1722
1722
+
<section id="results" class="results" aria-live="polite">
1723
1723
+
<div class="empty">Start with a name or handle, then write the part you remember.</div>
1724
1724
+
</section>
1725
1725
+
</section>
1726
1726
+
</main>
1727
1727
+
</div>
1728
1728
+
1729
1729
+
<script>
1730
1730
+
let selectedActor = null;
1731
1731
+
let actorOptions = [];
1732
1732
+
let activeActor = -1;
1733
1733
+
let actorTimer = null;
1734
1734
+
let isWorking = false;
1735
1735
+
1736
1736
+
const form = document.querySelector("#search");
1737
1737
+
const reset = document.querySelector("#reset");
1738
1738
+
const query = document.querySelector("#query");
1739
1739
+
const submit = document.querySelector("#submit");
1740
1740
+
const results = document.querySelector("#results");
1741
1741
+
const actorInput = document.querySelector("#actor-input");
1742
1742
+
const actorAvatar = document.querySelector("#actor-avatar");
1743
1743
+
const actorField = document.querySelector("#actor-field");
1744
1744
+
const actorMenu = document.querySelector("#actor-menu");
1745
1745
+
const progress = document.querySelector("#progress");
1746
1746
+
const progressFill = document.querySelector("#progress-fill");
1747
1747
+
const progressLabel = document.querySelector("#progress-label");
1748
1748
+
const progressCount = document.querySelector("#progress-count");
1749
1749
+
const progressDetail = document.querySelector("#progress-detail");
1750
1750
+
1751
1751
+
reset.addEventListener("click", () => {
1752
1752
+
selectedActor = null;
1753
1753
+
actorOptions = [];
1754
1754
+
activeActor = -1;
1755
1755
+
actorInput.value = "";
1756
1756
+
query.value = "";
1757
1757
+
clearSelectedActorImage();
1758
1758
+
actorMenu.classList.remove("open");
1759
1759
+
progress.classList.remove("active");
1760
1760
+
results.innerHTML = '<div class="empty">Start with a name or handle, then write the part you remember.</div>';
1761
1761
+
actorInput.focus();
1762
1762
+
});
1763
1763
+
1764
1764
+
actorInput.addEventListener("input", () => {
1765
1765
+
const normalized = actorInput.value.replace(/^@+/, "");
1766
1766
+
if (actorInput.value !== normalized) actorInput.value = normalized;
1767
1767
+
if (selectedActor?.handle !== normalized.trim()) {
1768
1768
+
selectedActor = null;
1769
1769
+
clearSelectedActorImage();
1770
1770
+
}
1771
1771
+
window.clearTimeout(actorTimer);
1772
1772
+
actorTimer = window.setTimeout(searchActors, 140);
1773
1773
+
});
1774
1774
+
1775
1775
+
actorInput.addEventListener("focus", () => {
1776
1776
+
if (actorOptions.length) actorMenu.classList.add("open");
1777
1777
+
});
1778
1778
+
1779
1779
+
actorInput.addEventListener("keydown", (event) => {
1780
1780
+
if (!actorMenu.classList.contains("open")) return;
1781
1781
+
if (event.key === "ArrowDown") {
1782
1782
+
event.preventDefault();
1783
1783
+
activeActor = Math.min(actorOptions.length - 1, activeActor + 1);
1784
1784
+
renderActorMenu();
1785
1785
+
}
1786
1786
+
if (event.key === "ArrowUp") {
1787
1787
+
event.preventDefault();
1788
1788
+
activeActor = Math.max(0, activeActor - 1);
1789
1789
+
renderActorMenu();
1790
1790
+
}
1791
1791
+
if (event.key === "Enter" && activeActor >= 0) {
1792
1792
+
event.preventDefault();
1793
1793
+
chooseActor(actorOptions[activeActor]);
1794
1794
+
}
1795
1795
+
if (event.key === "Escape") {
1796
1796
+
actorMenu.classList.remove("open");
1797
1797
+
}
1798
1798
+
});
1799
1799
+
1800
1800
+
document.addEventListener("click", (event) => {
1801
1801
+
if (!event.target.closest(".person-wrap")) actorMenu.classList.remove("open");
1802
1802
+
});
1803
1803
+
1804
1804
+
form.addEventListener("submit", async (event) => {
1805
1805
+
event.preventDefault();
1806
1806
+
actorMenu.classList.remove("open");
1807
1807
+
isWorking = true;
1808
1808
+
const q = query.value.trim();
1809
1809
+
if (!q) {
1810
1810
+
results.innerHTML = '<div class="error">Write what you are trying to find.</div>';
1811
1811
+
return;
1812
1812
+
}
1813
1813
+
1814
1814
+
submit.disabled = true;
1815
1815
+
document.querySelectorAll("#search button").forEach((button) => { button.disabled = true; });
1816
1816
+
progress.classList.remove("active");
1817
1817
+
results.innerHTML = '<div class="empty">Searching the public history...</div>';
1818
1818
+
1819
1819
+
try {
1820
1820
+
const actor = selectedActor?.handle || actorInput.value.replace(/^@+/, "").trim();
1821
1821
+
if (!actor) {
1822
1822
+
results.innerHTML = '<div class="error">Enter a name or handle first.</div>';
1823
1823
+
return;
1824
1824
+
}
1825
1825
+
const params = new URLSearchParams({ q, actor });
1826
1826
+
const response = await fetch("/api/search?" + params.toString());
1827
1827
+
const data = await response.json();
1828
1828
+
if (!response.ok) throw new Error(data.message || "Search is unavailable.");
1829
1829
+
if (data.needsIndex) {
1830
1830
+
await updateIndex(q, actor);
1831
1831
+
return;
1832
1832
+
}
1833
1833
+
renderResults(data);
1834
1834
+
} catch (error) {
1835
1835
+
results.innerHTML = '<div class="error">' + escapeHtml(error.message) + '</div>';
1836
1836
+
} finally {
1837
1837
+
isWorking = false;
1838
1838
+
document.querySelectorAll("#search button").forEach((button) => { button.disabled = false; });
1839
1839
+
}
1840
1840
+
});
1841
1841
+
1842
1842
+
async function updateIndex(q, actor) {
1843
1843
+
startProgress();
1844
1844
+
results.innerHTML = "";
1845
1845
+
const params = new URLSearchParams({ q, actor });
1846
1846
+
const response = await fetch("/api/update?" + params.toString());
1847
1847
+
if (!response.ok || !response.body) {
1848
1848
+
const data = await response.json().catch(() => ({}));
1849
1849
+
throw new Error(data.message || "Pensieve could not read that public history.");
1850
1850
+
}
1851
1851
+
1852
1852
+
const reader = response.body.getReader();
1853
1853
+
const decoder = new TextDecoder();
1854
1854
+
let buffer = "";
1855
1855
+
1856
1856
+
while (true) {
1857
1857
+
const { value, done } = await reader.read();
1858
1858
+
if (done) break;
1859
1859
+
buffer += decoder.decode(value, { stream: true });
1860
1860
+
const lines = buffer.split("\\n");
1861
1861
+
buffer = lines.pop() || "";
1862
1862
+
for (const line of lines) {
1863
1863
+
if (!line.trim()) continue;
1864
1864
+
handleUpdateEvent(JSON.parse(line));
1865
1865
+
}
1866
1866
+
}
1867
1867
+
if (buffer.trim()) handleUpdateEvent(JSON.parse(buffer));
1868
1868
+
}
1869
1869
+
1870
1870
+
function handleUpdateEvent(event) {
1871
1871
+
if (event.type === "progress") {
1872
1872
+
updateProgress(event);
1873
1873
+
return;
1874
1874
+
}
1875
1875
+
if (event.type === "done") {
1876
1876
+
updateProgress({ label: "done", pct: 100, indexed: event.data?.indexed?.indexed });
1877
1877
+
renderResults(event.data || {});
1878
1878
+
return;
1879
1879
+
}
1880
1880
+
if (event.type === "error") {
1881
1881
+
throw new Error(event.message || "Pensieve could not read that public history.");
1882
1882
+
}
1883
1883
+
}
1884
1884
+
1885
1885
+
function startProgress() {
1886
1886
+
progress.classList.add("active");
1887
1887
+
progressFill.style.width = "0%";
1888
1888
+
progressLabel.textContent = "starting";
1889
1889
+
progressCount.textContent = "";
1890
1890
+
progressDetail.textContent = "Looking through public posts and notes. This can take a moment the first time.";
1891
1891
+
}
1892
1892
+
1893
1893
+
function updateProgress(event) {
1894
1894
+
const pct = Math.max(0, Math.min(100, Number(event.pct || 0)));
1895
1895
+
progressFill.style.width = pct + "%";
1896
1896
+
progressLabel.textContent = progressLabelFor(event);
1897
1897
+
if (event.total) {
1898
1898
+
progressCount.textContent = Number(event.indexed || 0).toLocaleString() + " / " + Number(event.total).toLocaleString();
1899
1899
+
} else if (event.scanned) {
1900
1900
+
progressCount.textContent = Number(event.scanned).toLocaleString() + " items";
1901
1901
+
} else if (event.indexed) {
1902
1902
+
progressCount.textContent = Number(event.indexed).toLocaleString() + " ready";
1903
1903
+
} else {
1904
1904
+
progressCount.textContent = "";
1905
1905
+
}
1906
1906
+
if (event.phase === "walk") {
1907
1907
+
progressDetail.textContent = "Gathering the public things this person has shared.";
1908
1908
+
} else if (event.phase === "embed") {
1909
1909
+
progressDetail.textContent = "Preparing those items so they can be searched by meaning.";
1910
1910
+
} else if (event.phase === "query") {
1911
1911
+
progressDetail.textContent = "Searching the updated history.";
1912
1912
+
}
1913
1913
+
}
1914
1914
+
1915
1915
+
function progressLabelFor(event) {
1916
1916
+
if (event.phase === "resolve") return "finding that person";
1917
1917
+
if (event.phase === "prepare") return "getting ready";
1918
1918
+
if (event.phase === "fetch") return "opening public history";
1919
1919
+
if (event.phase === "walk") return "looking through public history";
1920
1920
+
if (event.phase === "embed") return "preparing search";
1921
1921
+
if (event.phase === "query") return "searching";
1922
1922
+
return event.label || event.phase || "working";
1923
1923
+
}
1924
1924
+
1925
1925
+
async function searchActors() {
1926
1926
+
const q = actorInput.value.replace(/^@+/, "").trim();
1927
1927
+
if (isWorking || q.length < 2) {
1928
1928
+
actorMenu.classList.remove("open");
1929
1929
+
return;
1930
1930
+
}
1931
1931
+
1932
1932
+
const response = await fetch("/api/actors?q=" + encodeURIComponent(q));
1933
1933
+
const data = await response.json();
1934
1934
+
if (isWorking) {
1935
1935
+
actorMenu.classList.remove("open");
1936
1936
+
return;
1937
1937
+
}
1938
1938
+
actorOptions = data.actors || [];
1939
1939
+
activeActor = actorOptions.length ? 0 : -1;
1940
1940
+
renderActorMenu();
1941
1941
+
}
1942
1942
+
1943
1943
+
function renderActorMenu() {
1944
1944
+
if (!actorOptions.length) {
1945
1945
+
actorMenu.classList.remove("open");
1946
1946
+
actorMenu.innerHTML = "";
1947
1947
+
return;
1948
1948
+
}
1949
1949
+
1950
1950
+
actorMenu.innerHTML = actorOptions.map((actor, index) => {
1951
1951
+
const avatar = actor.avatar
1952
1952
+
? '<img class="avatar" src="' + escapeAttr(actor.avatar) + '" alt="">'
1953
1953
+
: '<span class="avatar"></span>';
1954
1954
+
return '<button type="button" class="actor' + (index === activeActor ? ' active' : '') + '" data-index="' + index + '">' +
1955
1955
+
avatar +
1956
1956
+
'<span><strong>' + escapeHtml(actor.displayName || actor.handle) + '</strong><span>@' + escapeHtml(actor.handle) + '</span></span>' +
1957
1957
+
'</button>';
1958
1958
+
}).join("");
1959
1959
+
1960
1960
+
actorMenu.querySelectorAll("[data-index]").forEach((button) => {
1961
1961
+
button.addEventListener("click", () => chooseActor(actorOptions[Number(button.dataset.index)]));
1962
1962
+
});
1963
1963
+
actorMenu.classList.add("open");
1964
1964
+
}
1965
1965
+
1966
1966
+
function chooseActor(actor) {
1967
1967
+
selectedActor = actor;
1968
1968
+
actorInput.value = actor.handle;
1969
1969
+
results.innerHTML = '<div class="empty">' + escapeHtml(actor.handle) + ' is selected. Write the part you remember.</div>';
1970
1970
+
if (actor.avatar) {
1971
1971
+
actorAvatar.src = actor.avatar;
1972
1972
+
actorField.classList.add("has-avatar");
1973
1973
+
} else {
1974
1974
+
clearSelectedActorImage();
1975
1975
+
}
1976
1976
+
actorMenu.classList.remove("open");
1977
1977
+
}
1978
1978
+
1979
1979
+
function clearSelectedActorImage() {
1980
1980
+
actorAvatar.removeAttribute("src");
1981
1981
+
actorField.classList.remove("has-avatar");
1982
1982
+
}
1983
1983
+
1984
1984
+
function renderResults(data) {
1985
1985
+
const rows = data.results || [];
1986
1986
+
const prefix = renderSearchNote(data);
1987
1987
+
1988
1988
+
if (!rows.length) {
1989
1989
+
results.innerHTML = prefix + '<div class="empty">Nothing matched. Try describing it another way.</div>';
1990
1990
+
return;
1991
1991
+
}
1992
1992
+
1993
1993
+
results.innerHTML = prefix + rows.map((row, index) => {
1994
1994
+
const collection = row.collection || collectionFromUri(row.uri);
1995
1995
+
const artifact = normalizeArtifact(row, collection);
1996
1996
+
const date = row.date ? '<time>' + escapeHtml(row.date) + '</time>' : '<span>undated</span>';
1997
1997
+
const app = appFallback(collection);
1998
1998
+
const rawRecord = row.uri ? recordUrl(row.uri) : "";
1999
1999
+
const primaryUrl = resultPrimaryUrl(row.uri, collection, artifact);
2000
2000
+
const appUrl = app.url || appUrlForCollection(collection);
2001
2001
+
const link = primaryUrl ? '<a class="record-link" href="' + escapeAttr(primaryUrl) + '" target="_blank" rel="noopener">open</a>' : "";
2002
2002
+
const rawLink = rawRecord && rawRecord !== primaryUrl ? '<a class="raw-link" href="' + escapeAttr(rawRecord) + '" target="_blank" rel="noopener">details</a>' : "";
2003
2003
+
const count = row.count > 1 ? '<span class="result-count">' + Number(row.count).toLocaleString() + ' times</span>' : "";
2004
2004
+
const title = artifact.title ? '<h2 class="artifact-title">' + escapeHtml(artifact.title) + '</h2>' : "";
2005
2005
+
const body = artifact.body ? '<p class="artifact-body">' + escapeHtml(artifact.body) + '</p>' : "";
2006
2006
+
const url = artifact.url ? '<a class="artifact-link" href="' + escapeAttr(artifact.url) + '" target="_blank" rel="noopener">' + escapeHtml(readableUrl(artifact.url)) + '</a>' : "";
2007
2007
+
const stamp = appUrl
2008
2008
+
? '<a class="result-stamp app-link" href="' + escapeAttr(appUrl) + '" target="_blank" rel="noopener" aria-label="open ' + escapeAttr(app.name) + '">' + escapeHtml(app.initial) + '</a>'
2009
2009
+
: '<div class="result-stamp" aria-hidden="true">' + escapeHtml(app.initial) + '</div>';
2010
2010
+
const appName = appUrl
2011
2011
+
? '<a class="result-app-name result-app-link app-link" href="' + escapeAttr(appUrl) + '" target="_blank" rel="noopener">' + escapeHtml(app.name) + '</a>'
2012
2012
+
: '<span class="result-app-name">' + escapeHtml(app.name) + '</span>';
2013
2013
+
const mainContent = title || body ? title + body : '<p class="artifact-body">' + escapeHtml(row.body || "Untitled item") + '</p>';
2014
2014
+
const contentLink = primaryUrl ? '<a class="result-content-link" href="' + escapeAttr(primaryUrl) + '" target="_blank" rel="noopener">' + mainContent + '</a>' : mainContent;
2015
2015
+
return '<article class="result" tabindex="0" role="link" data-result="' + index + '" data-collection="' + escapeAttr(collection) + '" data-primary-url="' + escapeAttr(primaryUrl) + '">' +
2016
2016
+
stamp +
2017
2017
+
'<div class="result-main">' +
2018
2018
+
'<div class="result-head">' +
2019
2019
+
'<div class="result-app">' + appName + '<span class="result-type">' + escapeHtml(artifact.kind || recordKind(collection)) + '</span></div>' +
2020
2020
+
'<div class="result-meta">' + date + ' ' + count + ' ' + link + ' ' + rawLink + '</div>' +
2021
2021
+
'</div>' +
2022
2022
+
contentLink + url +
2023
2023
+
'</div>' +
2024
2024
+
'</article>';
2025
2025
+
}).join("");
2026
2026
+
2027
2027
+
bindResultCards();
2028
2028
+
paintAppBadges(rows);
2029
2029
+
}
2030
2030
+
2031
2031
+
function bindResultCards() {
2032
2032
+
results.querySelectorAll(".result[data-primary-url]").forEach((card) => {
2033
2033
+
const open = () => {
2034
2034
+
const url = card.dataset.primaryUrl;
2035
2035
+
if (url) window.open(url, "_blank", "noopener");
2036
2036
+
};
2037
2037
+
card.addEventListener("click", (event) => {
2038
2038
+
if (event.target.closest("a, button")) return;
2039
2039
+
open();
2040
2040
+
});
2041
2041
+
card.addEventListener("keydown", (event) => {
2042
2042
+
if (event.key !== "Enter" && event.key !== " ") return;
2043
2043
+
if (event.target.closest("a, button")) return;
2044
2044
+
event.preventDefault();
2045
2045
+
open();
2046
2046
+
});
2047
2047
+
});
2048
2048
+
}
2049
2049
+
2050
2050
+
function renderSearchNote(data) {
2051
2051
+
if (data.indexed) {
2052
2052
+
const indexed = Number(data.indexed.indexed || 0).toLocaleString();
2053
2053
+
const scanned = Number(data.indexed.scanned || 0).toLocaleString();
2054
2054
+
return '<div class="empty message">Pensieve read ' + scanned + ' public items and kept ' + indexed + ' that can be searched.</div>';
2055
2055
+
}
2056
2056
+
if (data.needsIndex) {
2057
2057
+
return '<div class="empty message">Pensieve has not read this public history yet.</div>';
2058
2058
+
}
2059
2059
+
return "";
2060
2060
+
}
2061
2061
+
2062
2062
+
function recordUrl(uri) {
2063
2063
+
return String(uri).startsWith("at://") ? "https://pds.ls/" + String(uri) : uri;
2064
2064
+
}
2065
2065
+
2066
2066
+
function resultPrimaryUrl(uri, collection, artifact) {
2067
2067
+
if (artifact?.url) return artifact.url;
2068
2068
+
const native = nativeRecordUrl(uri, collection);
2069
2069
+
if (native) return native;
2070
2070
+
return uri ? recordUrl(uri) : "";
2071
2071
+
}
2072
2072
+
2073
2073
+
function nativeRecordUrl(uri, collection) {
2074
2074
+
const parsed = parseAtUri(uri);
2075
2075
+
if (!parsed) return "";
2076
2076
+
if (collection === "app.bsky.feed.post") {
2077
2077
+
return "https://bsky.app/profile/" + encodeURIComponent(parsed.repo) + "/post/" + encodeURIComponent(parsed.rkey);
2078
2078
+
}
2079
2079
+
if (collection.startsWith("sh.tangled.")) {
2080
2080
+
return "https://tangled.org/" + encodeURIComponent(parsed.repo) + "/" + encodeURIComponent(collection) + "/" + encodeURIComponent(parsed.rkey);
2081
2081
+
}
2082
2082
+
return "";
2083
2083
+
}
2084
2084
+
2085
2085
+
function parseAtUri(uri) {
2086
2086
+
const match = String(uri || "").match(/^at:\\/\\/([^/]+)\\/([^/]+)\\/([^/]+)$/);
2087
2087
+
return match ? { repo: match[1], collection: match[2], rkey: match[3] } : null;
2088
2088
+
}
2089
2089
+
2090
2090
+
function collectionFromUri(uri) {
2091
2091
+
const parts = String(uri || "").split("/");
2092
2092
+
return parts.length >= 4 ? parts[3] : "";
2093
2093
+
}
2094
2094
+
2095
2095
+
function recordKind(collection) {
2096
2096
+
const leaf = String(collection || "").split(".").pop() || "item";
2097
2097
+
return humanWords(leaf);
2098
2098
+
}
2099
2099
+
2100
2100
+
function normalizeArtifact(row, collection) {
2101
2101
+
const artifact = row.artifact && typeof row.artifact === "object" ? row.artifact : {};
2102
2102
+
const fallback = row.body || "";
2103
2103
+
return {
2104
2104
+
kind: artifact.kind || recordKind(collection),
2105
2105
+
title: artifact.title || "",
2106
2106
+
body: artifact.body || (!artifact.title ? fallback : ""),
2107
2107
+
url: artifact.url || firstUrl(fallback),
2108
2108
+
media: Array.isArray(artifact.media) ? artifact.media : [],
2109
2109
+
refs: Array.isArray(artifact.refs) ? artifact.refs : [],
2110
2110
+
confidence: artifact.confidence || "low"
2111
2111
+
};
2112
2112
+
}
2113
2113
+
2114
2114
+
function firstUrl(value) {
2115
2115
+
return String(value || "").match(/https?:\\/\\/[^\\s)]+/)?.[0] || "";
2116
2116
+
}
2117
2117
+
2118
2118
+
function readableUrl(value) {
2119
2119
+
try {
2120
2120
+
const url = new URL(value);
2121
2121
+
return url.hostname.replace(/^www\\./, "") + url.pathname.replace(/\\/$/, "");
2122
2122
+
} catch {
2123
2123
+
return value;
2124
2124
+
}
2125
2125
+
}
2126
2126
+
2127
2127
+
const appBadgeCache = new Map();
2128
2128
+
const domainRedirects = { "tangled.sh": "tangled.org" };
2129
2129
+
2130
2130
+
function appFallback(collection) {
2131
2131
+
const domain = collectionDomain(collection);
2132
2132
+
const name = domain ? domain.replace(/^www\\./, "") : "public record";
2133
2133
+
return { name, initial: name.slice(0, 1).toUpperCase() || "P", icon: "", url: domain ? "https://" + domain : "" };
2134
2134
+
}
2135
2135
+
2136
2136
+
function collectionDomain(collection) {
2137
2137
+
const parts = String(collection || "").split(".");
2138
2138
+
if (parts.length < 2) return "";
2139
2139
+
const domain = parts[1] + "." + parts[0];
2140
2140
+
return domainRedirects[domain] || domain;
2141
2141
+
}
2142
2142
+
2143
2143
+
function appUrlForCollection(collection) {
2144
2144
+
const domain = collectionDomain(collection);
2145
2145
+
return domain ? "https://" + domain : "";
2146
2146
+
}
2147
2147
+
2148
2148
+
async function paintAppBadges(rows) {
2149
2149
+
const collections = [...new Set(rows.map((row) => row.collection || collectionFromUri(row.uri)).filter(Boolean))];
2150
2150
+
if (!collections.length) return;
2151
2151
+
await Promise.all(collections.map(resolveAppBadge));
2152
2152
+
document.querySelectorAll(".result[data-collection]").forEach((card) => {
2153
2153
+
const meta = appBadgeCache.get(card.dataset.collection);
2154
2154
+
if (!meta) return;
2155
2155
+
const stamp = card.querySelector(".result-stamp");
2156
2156
+
const name = card.querySelector(".result-app-name");
2157
2157
+
if (name) name.textContent = meta.name;
2158
2158
+
if (name?.tagName === "A" && meta.url) name.href = meta.url;
2159
2159
+
if (stamp?.tagName === "A" && meta.url) stamp.href = meta.url;
2160
2160
+
if (stamp && meta.icon) {
2161
2161
+
stamp.innerHTML = '<img src="' + escapeAttr(meta.icon) + '" alt="">';
2162
2162
+
} else if (stamp) {
2163
2163
+
stamp.textContent = meta.initial;
2164
2164
+
}
2165
2165
+
});
2166
2166
+
}
2167
2167
+
2168
2168
+
async function resolveAppBadge(collection) {
2169
2169
+
if (appBadgeCache.has(collection)) return appBadgeCache.get(collection);
2170
2170
+
const fallback = appFallback(collection);
2171
2171
+
appBadgeCache.set(collection, fallback);
2172
2172
+
2173
2173
+
const domain = collectionDomain(collection);
2174
2174
+
if (!domain) return fallback;
2175
2175
+
2176
2176
+
try {
2177
2177
+
const profileResponse = await fetch("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" + encodeURIComponent(domain), {
2178
2178
+
signal: AbortSignal.timeout(3500)
2179
2179
+
});
2180
2180
+
if (!profileResponse.ok) return fallback;
2181
2181
+
const profile = await profileResponse.json();
2182
2182
+
let meta = {
2183
2183
+
name: profile.displayName || fallback.name,
2184
2184
+
initial: (profile.displayName || fallback.name).slice(0, 1).toUpperCase() || fallback.initial,
2185
2185
+
icon: profile.avatar || "",
2186
2186
+
url: "https://bsky.app/profile/" + encodeURIComponent(profile.handle || domain)
2187
2187
+
};
2188
2188
+
2189
2189
+
const appResponse = await fetch("https://public.api.bsky.app/xrpc/com.atproto.repo.getRecord?repo=" + encodeURIComponent(profile.did) + "&collection=community.lexicon.app.profile&rkey=self", {
2190
2190
+
signal: AbortSignal.timeout(3500)
2191
2191
+
});
2192
2192
+
if (appResponse.ok) {
2193
2193
+
const appRecord = await appResponse.json();
2194
2194
+
meta = mergeAppProfile(meta, appRecord.value, profile.did);
2195
2195
+
}
2196
2196
+
appBadgeCache.set(collection, meta);
2197
2197
+
return meta;
2198
2198
+
} catch (error) {
2199
2199
+
return fallback;
2200
2200
+
}
2201
2201
+
}
2202
2202
+
2203
2203
+
function mergeAppProfile(meta, value, did) {
2204
2204
+
if (!value || typeof value !== "object") return meta;
2205
2205
+
const name = typeof value.name === "string" && value.name.trim() ? value.name.trim() : meta.name;
2206
2206
+
const icon = appProfileIcon(value, did) || meta.icon;
2207
2207
+
return {
2208
2208
+
name,
2209
2209
+
initial: name.slice(0, 1).toUpperCase() || meta.initial,
2210
2210
+
icon,
2211
2211
+
url: meta.url
2212
2212
+
};
2213
2213
+
}
2214
2214
+
2215
2215
+
function appProfileIcon(value, did) {
2216
2216
+
const images = Array.isArray(value.images) ? value.images : [];
2217
2217
+
const preferred = images.find((image) => image?.purpose === "community.lexicon.app.defs#purposeIcon") ||
2218
2218
+
images.find((image) => image?.purpose === "community.lexicon.app.defs#purposeLogo") ||
2219
2219
+
images.find((image) => image?.uri);
2220
2220
+
if (!preferred) return "";
2221
2221
+
if (typeof preferred.uri === "string") return preferred.uri;
2222
2222
+
const cid = preferred.image?.ref?.$link || preferred.image?.ref?.["$link"] || preferred.image?.["$link"];
2223
2223
+
return cid && did ? "https://cdn.bsky.app/img/avatar/plain/" + encodeURIComponent(did) + "/" + encodeURIComponent(cid) : "";
2224
2224
+
}
2225
2225
+
2226
2226
+
function humanWords(value) {
2227
2227
+
return String(value || "item")
2228
2228
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
2229
2229
+
.replace(/[-_]+/g, " ")
2230
2230
+
.toLowerCase();
2231
2231
+
}
2232
2232
+
2233
2233
+
function escapeHtml(value) {
2234
2234
+
return String(value).replace(/[&<>"']/g, (char) => ({
2235
2235
+
"&": "&",
2236
2236
+
"<": "<",
2237
2237
+
">": ">",
2238
2238
+
'"': """,
2239
2239
+
"'": "'"
2240
2240
+
})[char]);
2241
2241
+
}
2242
2242
+
2243
2243
+
function escapeAttr(value) {
2244
2244
+
return escapeHtml(value);
2245
2245
+
}
2246
2246
+
</script>
2247
2247
+
</body>
2248
2248
+
</html>`;
2249
2249
+
}
2250
2250
+
2251
2251
+
function escapeHtml(value) {
2252
2252
+
return String(value).replace(/[&<>"']/g, (char) => ({
2253
2253
+
"&": "&",
2254
2254
+
"<": "<",
2255
2255
+
">": ">",
2256
2256
+
'"': """,
2257
2257
+
"'": "'"
2258
2258
+
})[char]);
2259
2259
+
}
2260
2260
+
2261
2261
+
function escapeAttr(value) {
2262
2262
+
return escapeHtml(value);
2263
2263
+
}
···
1
1
+
{
2
2
+
"$schema": "node_modules/wrangler/config-schema.json",
3
3
+
"name": "pensieve",
4
4
+
"account_id": "3e9ba01cd687b3c4d29033908177072e",
5
5
+
"main": "src/worker.js",
6
6
+
"compatibility_date": "2026-06-29",
7
7
+
"workers_dev": true,
8
8
+
"observability": {
9
9
+
"enabled": true
10
10
+
},
11
11
+
"vars": {
12
12
+
"TURBOPUFFER_NAMESPACE_PREFIX": "pensieve"
13
13
+
}
14
14
+
}