@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 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
- // e.g. official SDK low-level Server, prism-mcp-server, or your own dispatcher
79
- register({ name: tool.name, description: tool.description, inputSchema: tool.inputSchema },
80
- async (args) => ({ content: [{ type: 'text', text: await tool.call(args) }] }));
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
- constructor(store: GraphStore, vectors?: VectorStore | null, embedder?: Embedder | null, policy?: AcceptancePolicy);
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
- constructor(memoryScope: Scope, store: GraphStore, vectors: VectorStore | null, embedder: Embedder | null, policy: AcceptancePolicy);
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
- constructor(store, vectors = null, embedder = null, policy = new DefaultAcceptancePolicy()) {
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
- constructor(memoryScope, store, vectors, embedder, policy) {
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';
@@ -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;
@@ -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.1.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,