@particle-academy/prism-graph 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +40 -3
- package/dist/graph.d.ts +27 -3
- package/dist/graph.js +94 -4
- package/dist/index.d.ts +1 -0
- package/dist/storage/store.d.ts +44 -0
- package/dist/storage/store.js +67 -7
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Particle Academy
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Particle Academy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -54,6 +54,11 @@ await memory.ingest('entry-42', 'journal://42', text, new MarkdownChunker(), new
|
|
|
54
54
|
const hits = await memory.search('refund policy'); // verbatim chunks, with trust and source
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
`baseSchema()` is the complete schema for the default setup, and already
|
|
58
|
+
includes the full-text table and its triggers. Do not run `fullTextSchema()`
|
|
59
|
+
as well. Run `vectorSchema()` only if you store vectors. If a migration rebuilds
|
|
60
|
+
the chunks table (which drops its triggers), run `repairFullTextSchema()`.
|
|
61
|
+
|
|
57
62
|
### Give memory a connection of its own
|
|
58
63
|
|
|
59
64
|
SQLite transactions belong to the connection. Suppose another part of your
|
|
@@ -63,6 +68,27 @@ with it if prism-graph's write fails. So use a dedicated connection to the same
|
|
|
63
68
|
file (WAL mode is the usual setup), or send every write on the shared
|
|
64
69
|
connection through `driver.atomic()`.
|
|
65
70
|
|
|
71
|
+
## Listing sources and chunks
|
|
72
|
+
|
|
73
|
+
For a memory explorer, list what a scope can see, a page at a time:
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const page = await memory.listSources(); // newest first, 50 per page
|
|
77
|
+
const next = await memory.listSources(page.nextCursor);
|
|
78
|
+
const chunks = await memory.listChunks(sourceId, null, 100);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Each call returns `{ contractVersion: 'g282.1', items, nextCursor }`, the same shape as the PHP package.
|
|
82
|
+
- A source item has `sourceId`, `title` (`metadata.title`, else the uri), `kind` (`metadata.kind`, else null),
|
|
83
|
+
`createdAt`, `chunkCount` and `trust`.
|
|
84
|
+
- A chunk item has `chunkId`, `sourceId`, `text`, `position` (its place in the source) and `provenance` (uri,
|
|
85
|
+
sha256, byte and line range, heading path, recipe, trust, writer, recordedAt).
|
|
86
|
+
- **Scoped like every read.** A source outside the scope lists as empty.
|
|
87
|
+
- **Cursors are opaque** and only work in the scope and listing that issued them.
|
|
88
|
+
- **Page size** defaults to 50 and is capped at 200. Pass a different cap as `new Graph(store, vectors, embedder,
|
|
89
|
+
policy, listMax)`.
|
|
90
|
+
- Retired sources and chunks are not listed.
|
|
91
|
+
|
|
66
92
|
## MCP tools on your own server
|
|
67
93
|
|
|
68
94
|
`memoryTools()` returns framework-neutral tool definitions: a name, a
|
|
@@ -71,16 +97,27 @@ resolves to a JSON string and never rejects at the model. They can be
|
|
|
71
97
|
registered on any MCP server, with no MCP SDK dependency. The scope and the
|
|
72
98
|
writer are bound by your code, never by the model.
|
|
73
99
|
|
|
100
|
+
With [`@particle-academy/prism-mcp-server`](https://github.com/Particle-Academy/prism-mcp-server-ts)
|
|
101
|
+
(this is the setup the test suite runs over a real MCP exchange):
|
|
102
|
+
|
|
74
103
|
```ts
|
|
104
|
+
import { LoopbackHttpTransport, Server, ToolDefinition, type JsonObject } from '@particle-academy/prism-mcp-server';
|
|
75
105
|
import { memoryTools } from '@particle-academy/prism-graph';
|
|
76
106
|
|
|
107
|
+
const server = new Server({ server: 'journal', transport: new LoopbackHttpTransport() });
|
|
77
108
|
for (const tool of memoryTools(memory, 'agent:journal')) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
109
|
+
server.register(
|
|
110
|
+
new ToolDefinition(tool.name, tool.description, tool.inputSchema as JsonObject),
|
|
111
|
+
async (args) => ({ text: await tool.call(args), isError: false }),
|
|
112
|
+
);
|
|
81
113
|
}
|
|
114
|
+
await server.listen();
|
|
82
115
|
```
|
|
83
116
|
|
|
117
|
+
`inputSchema` is plain JSON Schema, so any MCP server that takes a tool's
|
|
118
|
+
JSON Schema as-is can register these tools without converting anything. No
|
|
119
|
+
schema library is needed: `call()` validates its own input either way.
|
|
120
|
+
|
|
84
121
|
The tools are `memory_search`, `memory_get`, `memory_facts`, `memory_timeline`
|
|
85
122
|
and `memory_note`:
|
|
86
123
|
- No tool asserts, accepts, supersedes or erases. Those are app decisions.
|
package/dist/graph.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Chunk, Chunker } from './chunk.js';
|
|
2
2
|
import type { EraseReport, Fact, IngestReport } from './fact.js';
|
|
3
3
|
import type { Scope } from './scope.js';
|
|
4
|
-
import type { GraphStore } from './storage/store.js';
|
|
4
|
+
import type { GraphStore, ListedChunk, ListedSource, ListingPage } from './storage/store.js';
|
|
5
5
|
import { type AcceptancePolicy, type Origin } from './trust.js';
|
|
6
6
|
import type { Embedder, VectorStore } from './vectors.js';
|
|
7
7
|
export interface Hit {
|
|
@@ -21,7 +21,11 @@ export declare class Graph {
|
|
|
21
21
|
private readonly vectors;
|
|
22
22
|
private readonly embedder;
|
|
23
23
|
private readonly policy;
|
|
24
|
-
|
|
24
|
+
/** The largest page listSources/listChunks will return (G-282). */
|
|
25
|
+
private readonly listMax;
|
|
26
|
+
constructor(store: GraphStore, vectors?: VectorStore | null, embedder?: Embedder | null, policy?: AcceptancePolicy,
|
|
27
|
+
/** The largest page listSources/listChunks will return (G-282). */
|
|
28
|
+
listMax?: number);
|
|
25
29
|
scope(scope: Scope): ScopedGraph;
|
|
26
30
|
}
|
|
27
31
|
/**
|
|
@@ -35,12 +39,32 @@ export declare class ScopedGraph {
|
|
|
35
39
|
private readonly vectors;
|
|
36
40
|
private readonly embedder;
|
|
37
41
|
private readonly policy;
|
|
42
|
+
private readonly listMax;
|
|
38
43
|
static readonly MAX_LIMIT = 200;
|
|
39
44
|
/** A query goes to the embedding provider and the full-text index, so it is bounded. */
|
|
40
45
|
static readonly MAX_QUERY_BYTES = 4096;
|
|
41
46
|
static readonly MAX_GET_IDS = 1000;
|
|
42
47
|
private static readonly RRF_K;
|
|
43
|
-
|
|
48
|
+
/** The listing contract (G-282). A consumer refuses a version it does not know. */
|
|
49
|
+
static readonly CONTRACT_VERSION: "g282.1";
|
|
50
|
+
static readonly DEFAULT_LIST_LIMIT = 50;
|
|
51
|
+
static readonly DEFAULT_LIST_MAX = 200;
|
|
52
|
+
constructor(memoryScope: Scope, store: GraphStore, vectors: VectorStore | null, embedder: Embedder | null, policy: AcceptancePolicy, listMax?: number);
|
|
53
|
+
/** Sources this scope can see, newest first, a page at a time (G-282). */
|
|
54
|
+
listSources(cursor?: string | null, limit?: number | null): Promise<ListingPage<ListedSource>>;
|
|
55
|
+
/**
|
|
56
|
+
* Live chunks of one source, in source order, a page at a time (G-282). A source outside this scope yields an
|
|
57
|
+
* empty page, never an error that would confirm it exists.
|
|
58
|
+
*/
|
|
59
|
+
listChunks(sourceId: string, cursor?: string | null, limit?: number | null): Promise<ListingPage<ListedChunk>>;
|
|
60
|
+
private listLimit;
|
|
61
|
+
/**
|
|
62
|
+
* Opaque, and bound to this scope and listing: a cursor from another scope or listing is refused. It needs no
|
|
63
|
+
* secret, because every listing query applies the scope predicate regardless of the cursor.
|
|
64
|
+
*/
|
|
65
|
+
private writeCursor;
|
|
66
|
+
private readCursor;
|
|
67
|
+
private cursorBinding;
|
|
44
68
|
scope(): Scope;
|
|
45
69
|
/**
|
|
46
70
|
* Store a source and its verbatim chunks at this scope. Idempotent per key:
|
package/dist/graph.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sha256 } from './digest.js';
|
|
1
|
+
import { digest, sha256 } from './digest.js';
|
|
2
2
|
import { GraphError, InvalidArgumentError } from './errors.js';
|
|
3
3
|
import { IndexSpec } from './index-spec.js';
|
|
4
4
|
import { DefaultAcceptancePolicy } from './trust.js';
|
|
@@ -13,17 +13,24 @@ export class Graph {
|
|
|
13
13
|
vectors;
|
|
14
14
|
embedder;
|
|
15
15
|
policy;
|
|
16
|
-
|
|
16
|
+
listMax;
|
|
17
|
+
constructor(store, vectors = null, embedder = null, policy = new DefaultAcceptancePolicy(),
|
|
18
|
+
/** The largest page listSources/listChunks will return (G-282). */
|
|
19
|
+
listMax = ScopedGraph.DEFAULT_LIST_MAX) {
|
|
17
20
|
this.store = store;
|
|
18
21
|
this.vectors = vectors;
|
|
19
22
|
this.embedder = embedder;
|
|
20
23
|
this.policy = policy;
|
|
24
|
+
this.listMax = listMax;
|
|
25
|
+
if (!Number.isInteger(listMax) || listMax < 1) {
|
|
26
|
+
throw new InvalidArgumentError('listMax must be a positive integer.');
|
|
27
|
+
}
|
|
21
28
|
if ((vectors === null) !== (embedder === null)) {
|
|
22
29
|
throw new InvalidArgumentError('Configure a VectorStore and an Embedder together, or neither.');
|
|
23
30
|
}
|
|
24
31
|
}
|
|
25
32
|
scope(scope) {
|
|
26
|
-
return new ScopedGraph(scope, this.store, this.vectors, this.embedder, this.policy);
|
|
33
|
+
return new ScopedGraph(scope, this.store, this.vectors, this.embedder, this.policy, this.listMax);
|
|
27
34
|
}
|
|
28
35
|
}
|
|
29
36
|
/**
|
|
@@ -37,17 +44,100 @@ export class ScopedGraph {
|
|
|
37
44
|
vectors;
|
|
38
45
|
embedder;
|
|
39
46
|
policy;
|
|
47
|
+
listMax;
|
|
40
48
|
static MAX_LIMIT = 200;
|
|
41
49
|
/** A query goes to the embedding provider and the full-text index, so it is bounded. */
|
|
42
50
|
static MAX_QUERY_BYTES = 4096;
|
|
43
51
|
static MAX_GET_IDS = 1000;
|
|
44
52
|
static RRF_K = 60;
|
|
45
|
-
|
|
53
|
+
/** The listing contract (G-282). A consumer refuses a version it does not know. */
|
|
54
|
+
static CONTRACT_VERSION = 'g282.1';
|
|
55
|
+
static DEFAULT_LIST_LIMIT = 50;
|
|
56
|
+
static DEFAULT_LIST_MAX = 200;
|
|
57
|
+
constructor(memoryScope, store, vectors, embedder, policy, listMax = ScopedGraph.DEFAULT_LIST_MAX) {
|
|
46
58
|
this.memoryScope = memoryScope;
|
|
47
59
|
this.store = store;
|
|
48
60
|
this.vectors = vectors;
|
|
49
61
|
this.embedder = embedder;
|
|
50
62
|
this.policy = policy;
|
|
63
|
+
this.listMax = listMax;
|
|
64
|
+
}
|
|
65
|
+
/** Sources this scope can see, newest first, a page at a time (G-282). */
|
|
66
|
+
async listSources(cursor = null, limit = null) {
|
|
67
|
+
const size = this.listLimit(limit);
|
|
68
|
+
let after = null;
|
|
69
|
+
if (cursor !== null) {
|
|
70
|
+
const key = this.readCursor(cursor, 'sources');
|
|
71
|
+
if (key.length !== 2 || typeof key[0] !== 'string' || typeof key[1] !== 'string') {
|
|
72
|
+
throw new InvalidArgumentError('Invalid cursor.');
|
|
73
|
+
}
|
|
74
|
+
after = [key[0], key[1]];
|
|
75
|
+
}
|
|
76
|
+
const rows = await this.store.listSources(this.memoryScope, after, size + 1);
|
|
77
|
+
const items = rows.slice(0, size);
|
|
78
|
+
const last = items.at(-1);
|
|
79
|
+
return {
|
|
80
|
+
contractVersion: ScopedGraph.CONTRACT_VERSION,
|
|
81
|
+
items,
|
|
82
|
+
nextCursor: rows.length > size && last !== undefined ? this.writeCursor('sources', [last.createdAt, last.sourceId]) : null,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Live chunks of one source, in source order, a page at a time (G-282). A source outside this scope yields an
|
|
87
|
+
* empty page, never an error that would confirm it exists.
|
|
88
|
+
*/
|
|
89
|
+
async listChunks(sourceId, cursor = null, limit = null) {
|
|
90
|
+
const size = this.listLimit(limit);
|
|
91
|
+
let afterSeq = null;
|
|
92
|
+
if (cursor !== null) {
|
|
93
|
+
const key = this.readCursor(cursor, `chunks:${sourceId}`);
|
|
94
|
+
if (key.length !== 1 || typeof key[0] !== 'number' || !Number.isInteger(key[0]) || key[0] < 0) {
|
|
95
|
+
throw new InvalidArgumentError('Invalid cursor.');
|
|
96
|
+
}
|
|
97
|
+
afterSeq = key[0];
|
|
98
|
+
}
|
|
99
|
+
const rows = await this.store.listChunks(this.memoryScope, sourceId, afterSeq, size + 1);
|
|
100
|
+
const page = rows.slice(0, size);
|
|
101
|
+
const last = page.at(-1);
|
|
102
|
+
return {
|
|
103
|
+
contractVersion: ScopedGraph.CONTRACT_VERSION,
|
|
104
|
+
// seq is the store's paging key; the contract's order is `position`.
|
|
105
|
+
items: page.map(({ seq: _seq, ...chunk }) => chunk),
|
|
106
|
+
nextCursor: rows.length > size && last !== undefined ? this.writeCursor(`chunks:${sourceId}`, [last.seq]) : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
listLimit(limit) {
|
|
110
|
+
const size = limit ?? Math.min(ScopedGraph.DEFAULT_LIST_LIMIT, this.listMax);
|
|
111
|
+
if (!Number.isInteger(size) || size < 1 || size > this.listMax) {
|
|
112
|
+
throw new InvalidArgumentError(`limit must be between 1 and ${this.listMax}.`);
|
|
113
|
+
}
|
|
114
|
+
return size;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Opaque, and bound to this scope and listing: a cursor from another scope or listing is refused. It needs no
|
|
118
|
+
* secret, because every listing query applies the scope predicate regardless of the cursor.
|
|
119
|
+
*/
|
|
120
|
+
writeCursor(listing, key) {
|
|
121
|
+
return Buffer.from(JSON.stringify({ k: key, b: this.cursorBinding(listing) }), 'utf8').toString('base64url');
|
|
122
|
+
}
|
|
123
|
+
readCursor(cursor, listing) {
|
|
124
|
+
let data = null;
|
|
125
|
+
if (cursor.length <= 1024 && /^[A-Za-z0-9_-]*$/.test(cursor)) {
|
|
126
|
+
try {
|
|
127
|
+
data = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
data = null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (typeof data !== 'object' || data === null || !Array.isArray(data.k)
|
|
134
|
+
|| data.b !== this.cursorBinding(listing)) {
|
|
135
|
+
throw new InvalidArgumentError('Invalid cursor.');
|
|
136
|
+
}
|
|
137
|
+
return data.k;
|
|
138
|
+
}
|
|
139
|
+
cursorBinding(listing) {
|
|
140
|
+
return digest('prism-graph/cursor@1', [...this.memoryScope.toRow().map((v) => v ?? ''), listing]).slice(0, 16);
|
|
51
141
|
}
|
|
52
142
|
scope() {
|
|
53
143
|
return this.memoryScope;
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export { Scope } from './scope.js';
|
|
|
10
10
|
export { NodeSqliteDriver, type Row, type SqlDriver, type SqlValue } from './storage/driver.js';
|
|
11
11
|
export { baseSchema, fullTextSchema, repairFullTextSchema, TableMap, vectorSchema } from './storage/schema.js';
|
|
12
12
|
export { ftsMatch, GraphStore, normaliseTime } from './storage/store.js';
|
|
13
|
+
export type { ListedChunk, ListedSource, ListingPage } from './storage/store.js';
|
|
13
14
|
export { MAX_IDS, MAX_NOTE_BYTES, MAX_RESULTS, memoryTools, type MemoryToolDefinition } from './tools.js';
|
|
14
15
|
export { type AcceptancePolicy, DefaultAcceptancePolicy, FactStatus, isTrust, Origin, Trust } from './trust.js';
|
|
15
16
|
export { float32, MAX_K, SqliteVectorStore, type Embedder, type VectorStore } from './vectors.js';
|
package/dist/storage/store.d.ts
CHANGED
|
@@ -17,6 +17,41 @@ export interface StoredSource {
|
|
|
17
17
|
* Every write runs in driver.atomic(): all-or-nothing, including inside the
|
|
18
18
|
* caller's own transaction.
|
|
19
19
|
*/
|
|
20
|
+
/** A source in a listing (G-282, contract g282.1). */
|
|
21
|
+
export interface ListedSource {
|
|
22
|
+
readonly sourceId: string;
|
|
23
|
+
readonly title: string;
|
|
24
|
+
readonly kind: string | null;
|
|
25
|
+
readonly createdAt: string;
|
|
26
|
+
readonly chunkCount: number;
|
|
27
|
+
readonly trust: string;
|
|
28
|
+
}
|
|
29
|
+
/** A chunk in a listing (G-282, contract g282.1). */
|
|
30
|
+
export interface ListedChunk {
|
|
31
|
+
readonly chunkId: string;
|
|
32
|
+
readonly sourceId: string;
|
|
33
|
+
readonly text: string;
|
|
34
|
+
readonly position: number;
|
|
35
|
+
readonly provenance: {
|
|
36
|
+
readonly uri: string;
|
|
37
|
+
readonly sha256: string;
|
|
38
|
+
readonly byteStart: number;
|
|
39
|
+
readonly byteEnd: number;
|
|
40
|
+
readonly lineStart: number;
|
|
41
|
+
readonly lineEnd: number;
|
|
42
|
+
readonly headingPath: string[];
|
|
43
|
+
readonly recipe: string;
|
|
44
|
+
readonly trust: string;
|
|
45
|
+
readonly writer: string;
|
|
46
|
+
readonly recordedAt: string;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** A page of a listing. A consumer refuses a contractVersion it does not know. */
|
|
50
|
+
export interface ListingPage<T> {
|
|
51
|
+
readonly contractVersion: 'g282.1';
|
|
52
|
+
readonly items: T[];
|
|
53
|
+
readonly nextCursor: string | null;
|
|
54
|
+
}
|
|
20
55
|
export declare class GraphStore {
|
|
21
56
|
private readonly db;
|
|
22
57
|
private readonly tables;
|
|
@@ -32,6 +67,15 @@ export declare class GraphStore {
|
|
|
32
67
|
retireSource(scope: Scope, key: string): Promise<number>;
|
|
33
68
|
/** Chunks visible from `reader`, in the order asked. */
|
|
34
69
|
getChunks(reader: Scope, ids: readonly string[]): Promise<Chunk[]>;
|
|
70
|
+
/**
|
|
71
|
+
* Sources visible from `reader`, newest first, with at least one live chunk (G-282, contract g282.1). Rows are in
|
|
72
|
+
* the agreed shape; the caller pages with `after` = [createdAt, sourceId] of the last item it returned.
|
|
73
|
+
*/
|
|
74
|
+
listSources(reader: Scope, after: readonly [string, string] | null, limit: number): Promise<ListedSource[]>;
|
|
75
|
+
/** Live chunks of one source visible from `reader`, in source order. A source outside the scope yields nothing. */
|
|
76
|
+
listChunks(reader: Scope, sourceId: string, afterSeq: number | null, limit: number): Promise<Array<ListedChunk & {
|
|
77
|
+
seq: number;
|
|
78
|
+
}>>;
|
|
35
79
|
/** Full-text search over live chunks visible from `reader`, best first. */
|
|
36
80
|
textSearch(reader: Scope, query: string, limit: number): Promise<Array<{
|
|
37
81
|
chunkId: string;
|
package/dist/storage/store.js
CHANGED
|
@@ -5,13 +5,6 @@ import { TableMap } from './schema.js';
|
|
|
5
5
|
export const MAX_QUERY_TERMS = 16;
|
|
6
6
|
const TIME = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(\.\d{1,6})?Z$/;
|
|
7
7
|
const KEY = /^[\x21-\x7E]{1,191}$/;
|
|
8
|
-
/**
|
|
9
|
-
* Scoped memory storage over a SqlDriver, a port of the PHP reference's
|
|
10
|
-
* PdoGraphStore. SCOPE IS PART OF EVERY QUERY: reads apply the visibility
|
|
11
|
-
* predicate in SQL, so a row outside the reader's scope is never fetched.
|
|
12
|
-
* Every write runs in driver.atomic(): all-or-nothing, including inside the
|
|
13
|
-
* caller's own transaction.
|
|
14
|
-
*/
|
|
15
8
|
export class GraphStore {
|
|
16
9
|
db;
|
|
17
10
|
tables;
|
|
@@ -79,6 +72,73 @@ export class GraphStore {
|
|
|
79
72
|
}
|
|
80
73
|
return wanted.map((id) => byId.get(id)).filter((c) => c !== undefined);
|
|
81
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Sources visible from `reader`, newest first, with at least one live chunk (G-282, contract g282.1). Rows are in
|
|
77
|
+
* the agreed shape; the caller pages with `after` = [createdAt, sourceId] of the last item it returned.
|
|
78
|
+
*/
|
|
79
|
+
async listSources(reader, after, limit) {
|
|
80
|
+
const [visible, bindings] = this.visible('s', reader);
|
|
81
|
+
const live = `SELECT 1 FROM ${this.tables.chunks} c WHERE c.source_id = s.id AND c.retired_at IS NULL`;
|
|
82
|
+
const page = after === null ? '' : ' AND (s.recorded_at < ? OR (s.recorded_at = ? AND s.id < ?))';
|
|
83
|
+
const rows = await this.db.all(`SELECT s.id, s.uri, s.metadata, s.trust, s.recorded_at,
|
|
84
|
+
(SELECT COUNT(*) FROM ${this.tables.chunks} c WHERE c.source_id = s.id AND c.retired_at IS NULL) AS chunk_count
|
|
85
|
+
FROM ${this.tables.sources} s
|
|
86
|
+
WHERE ${visible} AND EXISTS (${live})${page}
|
|
87
|
+
ORDER BY s.recorded_at DESC, s.id DESC
|
|
88
|
+
LIMIT ${Math.max(1, Math.min(1001, Math.trunc(limit)))}`, after === null ? bindings : [...bindings, after[0], after[0], after[1]]);
|
|
89
|
+
return rows.map((row) => {
|
|
90
|
+
let metadata;
|
|
91
|
+
try {
|
|
92
|
+
metadata = JSON.parse(String(row['metadata']));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
metadata = {};
|
|
96
|
+
}
|
|
97
|
+
const meta = typeof metadata === 'object' && metadata !== null ? metadata : {};
|
|
98
|
+
const title = typeof meta['title'] === 'string' && meta['title'] !== '' ? meta['title'] : String(row['uri']);
|
|
99
|
+
const kind = typeof meta['kind'] === 'string' && meta['kind'] !== '' ? meta['kind'] : null;
|
|
100
|
+
return {
|
|
101
|
+
sourceId: String(row['id']).trim(),
|
|
102
|
+
title,
|
|
103
|
+
kind,
|
|
104
|
+
createdAt: String(row['recorded_at']).trim(),
|
|
105
|
+
chunkCount: Number(row['chunk_count']),
|
|
106
|
+
trust: String(row['trust']),
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Live chunks of one source visible from `reader`, in source order. A source outside the scope yields nothing. */
|
|
111
|
+
async listChunks(reader, sourceId, afterSeq, limit) {
|
|
112
|
+
const [visible, bindings] = this.visible('s', reader);
|
|
113
|
+
const rows = await this.db.all(`SELECT * FROM (
|
|
114
|
+
SELECT c.seq, c.id, c.source_id, c.text, c.byte_start, c.byte_end, c.line_start, c.line_end,
|
|
115
|
+
c.heading_path, c.recipe, s.uri, s.sha256, s.trust, s.writer, s.recorded_at,
|
|
116
|
+
ROW_NUMBER() OVER (ORDER BY c.seq) AS position
|
|
117
|
+
FROM ${this.tables.chunks} c
|
|
118
|
+
JOIN ${this.tables.sources} s ON s.id = c.source_id
|
|
119
|
+
WHERE c.source_id = ? AND c.retired_at IS NULL AND ${visible}
|
|
120
|
+
) listed WHERE listed.seq > ? ORDER BY listed.seq LIMIT ${Math.max(1, Math.min(1001, Math.trunc(limit)))}`, [sourceId, ...bindings, afterSeq ?? 0]);
|
|
121
|
+
return rows.map((row) => ({
|
|
122
|
+
chunkId: String(row['id']).trim(),
|
|
123
|
+
sourceId: String(row['source_id']).trim(),
|
|
124
|
+
text: String(row['text']),
|
|
125
|
+
position: Number(row['position']),
|
|
126
|
+
seq: Number(row['seq']),
|
|
127
|
+
provenance: {
|
|
128
|
+
uri: String(row['uri']),
|
|
129
|
+
sha256: String(row['sha256']).trim(),
|
|
130
|
+
byteStart: Number(row['byte_start']),
|
|
131
|
+
byteEnd: Number(row['byte_end']),
|
|
132
|
+
lineStart: Number(row['line_start']),
|
|
133
|
+
lineEnd: Number(row['line_end']),
|
|
134
|
+
headingPath: JSON.parse(String(row['heading_path'])),
|
|
135
|
+
recipe: String(row['recipe']),
|
|
136
|
+
trust: String(row['trust']),
|
|
137
|
+
writer: String(row['writer']),
|
|
138
|
+
recordedAt: String(row['recorded_at']).trim(),
|
|
139
|
+
},
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
82
142
|
/** Full-text search over live chunks visible from `reader`, best first. */
|
|
83
143
|
async textSearch(reader, query, limit) {
|
|
84
144
|
const terms = [...new Set(query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((t) => t !== ''))].slice(0, MAX_QUERY_TERMS);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@particle-academy/prism-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Long-term memory for AI agents, for TypeScript: scoped, trust-labelled, cited facts, hybrid search and erasure. The port of particle-academy/prism-graph.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|