@gmickel/gno 1.40.0 → 1.41.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/assets/skill/SKILL.md +17 -0
- package/assets/skill/cli-reference.md +48 -0
- package/assets/skill/mcp-reference.md +24 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.40.0.zip → gno-browser-clipper-v1.41.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.41.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +146 -7
- package/spec/db/schema.sql +17 -0
- package/spec/mcp.md +194 -0
- package/spec/output-schemas/memory-recall.schema.json +159 -0
- package/spec/output-schemas/memory-remember.schema.json +164 -0
- package/spec/output-schemas/status.schema.json +269 -54
- package/src/cli/commands/memory.ts +491 -0
- package/src/cli/commands/status.ts +23 -4
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +127 -0
- package/src/config/types.ts +7 -0
- package/src/core/audit-provenance.ts +91 -0
- package/src/core/audit-workspace.ts +17 -0
- package/src/core/memory-diagnostics.ts +144 -0
- package/src/core/memory-fence.ts +239 -0
- package/src/core/memory-recall.ts +269 -0
- package/src/core/memory-record.ts +435 -0
- package/src/core/memory-remember.ts +425 -0
- package/src/core/memory-types.ts +211 -0
- package/src/core/memory.ts +87 -0
- package/src/ingestion/sync.ts +17 -0
- package/src/mcp/http-egress.ts +2 -0
- package/src/mcp/tools/index.ts +43 -0
- package/src/mcp/tools/memory-recall.ts +122 -0
- package/src/mcp/tools/memory-remember.ts +177 -0
- package/src/mcp/tools/memory-shared.ts +80 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +8 -0
- package/src/sdk/client.ts +94 -1
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +28 -0
- package/src/serve/routes/api.ts +167 -0
- package/src/serve/server.ts +26 -0
- package/src/store/migrations/027-memory-scopes.ts +37 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +127 -3
- package/src/store/types.ts +54 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.40.0.zip.sha256 +0 -1
package/src/sdk/client.ts
CHANGED
|
@@ -38,7 +38,11 @@ import type {
|
|
|
38
38
|
GnoMoveNoteOptions,
|
|
39
39
|
GnoMultiGetOptions,
|
|
40
40
|
GnoQueryOptions,
|
|
41
|
+
GnoRecallInput,
|
|
42
|
+
GnoRecallResult,
|
|
41
43
|
GnoRefactorNoteResult,
|
|
44
|
+
GnoRememberInput,
|
|
45
|
+
GnoRememberResult,
|
|
42
46
|
GnoRenameNoteApplyOptions,
|
|
43
47
|
GnoRenameNoteOptions,
|
|
44
48
|
GnoSearchOptions,
|
|
@@ -113,6 +117,11 @@ import {
|
|
|
113
117
|
listKnowledgeChanges,
|
|
114
118
|
type KnowledgeDeltaServiceResult,
|
|
115
119
|
} from "../core/knowledge-delta";
|
|
120
|
+
import {
|
|
121
|
+
MemoryError,
|
|
122
|
+
type MemoryErrorCode,
|
|
123
|
+
MemoryService,
|
|
124
|
+
} from "../core/memory";
|
|
116
125
|
import { resolveNoteCreatePlan } from "../core/note-creation";
|
|
117
126
|
import { resolveNotePreset } from "../core/note-presets";
|
|
118
127
|
import {
|
|
@@ -144,6 +153,7 @@ import {
|
|
|
144
153
|
} from "../core/sections";
|
|
145
154
|
import { normalizeStructuredQueryInput } from "../core/structured-query";
|
|
146
155
|
import { parseAndValidateTagFilter } from "../core/tags";
|
|
156
|
+
import { writeLeasePath } from "../core/write-lease";
|
|
147
157
|
import {
|
|
148
158
|
defaultSyncService,
|
|
149
159
|
type SyncResult,
|
|
@@ -171,7 +181,7 @@ import {
|
|
|
171
181
|
multiGetDocuments,
|
|
172
182
|
} from "./documents";
|
|
173
183
|
import { runEmbed } from "./embed";
|
|
174
|
-
import { sdkError } from "./errors";
|
|
184
|
+
import { type GnoSdkErrorCode, sdkError } from "./errors";
|
|
175
185
|
|
|
176
186
|
interface OpenedClientState {
|
|
177
187
|
config: Config;
|
|
@@ -295,6 +305,41 @@ async function resolveClientState(
|
|
|
295
305
|
};
|
|
296
306
|
}
|
|
297
307
|
|
|
308
|
+
/** SDK error family per memory code; exhaustive so a new code fails to compile. */
|
|
309
|
+
const MEMORY_ERROR_TO_SDK: Readonly<Record<MemoryErrorCode, GnoSdkErrorCode>> =
|
|
310
|
+
{
|
|
311
|
+
MEMORY_TEXT_REQUIRED: "VALIDATION",
|
|
312
|
+
MEMORY_TEXT_TOO_LARGE: "VALIDATION",
|
|
313
|
+
MEMORY_QUERY_REQUIRED: "VALIDATION",
|
|
314
|
+
MEMORY_BUDGET_INVALID: "VALIDATION",
|
|
315
|
+
MEMORY_COLLECTION_REQUIRED: "VALIDATION",
|
|
316
|
+
MEMORY_COLLECTION_NOT_FOUND: "NOT_FOUND",
|
|
317
|
+
MEMORY_COLLECTION_UNMANAGED: "VALIDATION",
|
|
318
|
+
MEMORY_SCOPES_REQUIRED: "VALIDATION",
|
|
319
|
+
MEMORY_SCOPES_INVALID: "VALIDATION",
|
|
320
|
+
MEMORY_IDENTITY_REQUIRED: "VALIDATION",
|
|
321
|
+
MEMORY_DECISION_INVALID: "VALIDATION",
|
|
322
|
+
MEMORY_PREDECESSOR_REQUIRED: "VALIDATION",
|
|
323
|
+
MEMORY_PREDECESSOR_NOT_FOUND: "NOT_FOUND",
|
|
324
|
+
MEMORY_PREDECESSOR_HASH_MISMATCH: "RUNTIME",
|
|
325
|
+
MEMORY_SUPERSEDE_CONFLICT: "RUNTIME",
|
|
326
|
+
MEMORY_SUPERSEDE_PROJECTION_FAILED: "RUNTIME",
|
|
327
|
+
MEMORY_FENCED_REPLAY: "VALIDATION",
|
|
328
|
+
MEMORY_FENCED_DERIVED: "VALIDATION",
|
|
329
|
+
MEMORY_WRITE_LEASE_BUSY: "RUNTIME",
|
|
330
|
+
MEMORY_SYNC_FAILED: "RUNTIME",
|
|
331
|
+
MEMORY_QUERY_FAILED: "RUNTIME",
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Map a core MemoryError onto the SDK error family; the memory code survives in `details.code`. */
|
|
335
|
+
function toMemorySdkError(cause: unknown): unknown {
|
|
336
|
+
if (!(cause instanceof MemoryError)) return cause;
|
|
337
|
+
return sdkError(MEMORY_ERROR_TO_SDK[cause.code], cause.message, {
|
|
338
|
+
cause,
|
|
339
|
+
details: { code: cause.code },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
298
343
|
class GnoClientImpl implements GnoClient {
|
|
299
344
|
config: Config;
|
|
300
345
|
readonly dbPath: string;
|
|
@@ -1581,6 +1626,54 @@ class GnoClientImpl implements GnoClient {
|
|
|
1581
1626
|
};
|
|
1582
1627
|
}
|
|
1583
1628
|
|
|
1629
|
+
/**
|
|
1630
|
+
* The memory service owns the shared write lease; the SDK never takes it.
|
|
1631
|
+
* Embedding is best-effort: without a local model the service reports
|
|
1632
|
+
* lexical-only matching/retrieval instead of failing.
|
|
1633
|
+
*/
|
|
1634
|
+
private async withMemoryService<T>(
|
|
1635
|
+
collection: string | undefined,
|
|
1636
|
+
run: (service: MemoryService) => Promise<T>
|
|
1637
|
+
): Promise<T> {
|
|
1638
|
+
this.assertOpen();
|
|
1639
|
+
const ports = await this.createRuntimePorts({
|
|
1640
|
+
embed: true,
|
|
1641
|
+
collection: this.config.collections.some(
|
|
1642
|
+
(candidate) => candidate.name === collection
|
|
1643
|
+
)
|
|
1644
|
+
? collection
|
|
1645
|
+
: undefined,
|
|
1646
|
+
});
|
|
1647
|
+
try {
|
|
1648
|
+
return await run(
|
|
1649
|
+
new MemoryService({
|
|
1650
|
+
store: this.store,
|
|
1651
|
+
config: this.config,
|
|
1652
|
+
collections: this.config.collections,
|
|
1653
|
+
lockPath: writeLeasePath(this.dbPath),
|
|
1654
|
+
embedPort: ports.embedPort,
|
|
1655
|
+
vectorIndex: ports.vectorIndex,
|
|
1656
|
+
})
|
|
1657
|
+
);
|
|
1658
|
+
} catch (cause) {
|
|
1659
|
+
throw toMemorySdkError(cause);
|
|
1660
|
+
} finally {
|
|
1661
|
+
await this.disposeRuntimePorts(ports);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
async remember(input: GnoRememberInput): Promise<GnoRememberResult> {
|
|
1666
|
+
return this.withMemoryService(input?.collection, (service) =>
|
|
1667
|
+
service.remember(input)
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
async recall(input: GnoRecallInput): Promise<GnoRecallResult> {
|
|
1672
|
+
return this.withMemoryService(input?.collection, (service) =>
|
|
1673
|
+
service.recall(input)
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1584
1677
|
async capture(options: GnoCaptureOptions): Promise<GnoCaptureResult> {
|
|
1585
1678
|
this.assertOpen();
|
|
1586
1679
|
const collection = this.getCollections(options.collection)[0];
|
package/src/sdk/index.ts
CHANGED
|
@@ -58,6 +58,10 @@ export type {
|
|
|
58
58
|
GnoMultiGetOptions,
|
|
59
59
|
GnoMultiGetResult,
|
|
60
60
|
GnoQueryOptions,
|
|
61
|
+
GnoRecallInput,
|
|
62
|
+
GnoRecallResult,
|
|
63
|
+
GnoRememberInput,
|
|
64
|
+
GnoRememberResult,
|
|
61
65
|
GnoRenameNoteApplyOptions,
|
|
62
66
|
GnoRenameNoteOptions,
|
|
63
67
|
GnoProjectHintOptions,
|
|
@@ -76,7 +80,16 @@ export type {
|
|
|
76
80
|
SectionTargetCreateSelector,
|
|
77
81
|
SectionTargetResolveResult,
|
|
78
82
|
SectionTargetV1,
|
|
83
|
+
MemoryCandidate,
|
|
84
|
+
MemoryFact,
|
|
85
|
+
MemoryRecallReceipt,
|
|
86
|
+
RecalledFact,
|
|
79
87
|
} from "./types";
|
|
88
|
+
export {
|
|
89
|
+
MemoryError,
|
|
90
|
+
type MemoryDecision,
|
|
91
|
+
type MemoryErrorCode,
|
|
92
|
+
} from "../core/memory";
|
|
80
93
|
export {
|
|
81
94
|
ContextCapsuleContractError,
|
|
82
95
|
type ContextCapsuleErrorCode,
|
package/src/sdk/types.ts
CHANGED
|
@@ -43,6 +43,16 @@ import type {
|
|
|
43
43
|
KnowledgeImpactResult,
|
|
44
44
|
ListKnowledgeChangesInput,
|
|
45
45
|
} from "../core/knowledge-delta";
|
|
46
|
+
import type {
|
|
47
|
+
MemoryCandidate,
|
|
48
|
+
MemoryFact,
|
|
49
|
+
MemoryRecallReceipt,
|
|
50
|
+
RecalledFact,
|
|
51
|
+
RecallInput,
|
|
52
|
+
RecallResult,
|
|
53
|
+
RememberInput,
|
|
54
|
+
RememberResult,
|
|
55
|
+
} from "../core/memory";
|
|
46
56
|
import type { NoteCollisionPolicy } from "../core/note-creation";
|
|
47
57
|
import type { NotePresetId } from "../core/note-presets";
|
|
48
58
|
import type {
|
|
@@ -245,6 +255,13 @@ export interface GnoCaptureOptions extends Omit<CaptureInput, "overwrite"> {}
|
|
|
245
255
|
|
|
246
256
|
export type GnoCaptureResult = CaptureReceipt;
|
|
247
257
|
|
|
258
|
+
/** Shared memory contract (identical on CLI, MCP, REST, and SDK). */
|
|
259
|
+
export type GnoRememberInput = RememberInput;
|
|
260
|
+
export type GnoRememberResult = RememberResult;
|
|
261
|
+
export type GnoRecallInput = RecallInput;
|
|
262
|
+
export type GnoRecallResult = RecallResult;
|
|
263
|
+
export type { MemoryCandidate, MemoryFact, MemoryRecallReceipt, RecalledFact };
|
|
264
|
+
|
|
248
265
|
export interface GnoCreateFolderOptions {
|
|
249
266
|
collection: string;
|
|
250
267
|
name: string;
|
|
@@ -370,6 +387,17 @@ export interface GnoClient {
|
|
|
370
387
|
embed(options?: GnoEmbedOptions): Promise<GnoEmbedResult>;
|
|
371
388
|
index(options?: GnoIndexOptions): Promise<GnoIndexResult>;
|
|
372
389
|
capture(options: GnoCaptureOptions): Promise<GnoCaptureResult>;
|
|
390
|
+
/**
|
|
391
|
+
* Store one fact in a memory-managed collection, or propose candidates when
|
|
392
|
+
* `decision` is omitted. Requires caller + session identity and explicit
|
|
393
|
+
* scopes. Errors carry the stable memory code in `details.code`.
|
|
394
|
+
*/
|
|
395
|
+
remember(input: GnoRememberInput): Promise<GnoRememberResult>;
|
|
396
|
+
/**
|
|
397
|
+
* Budgeted, cited recall of current facts in the caller's explicit scopes.
|
|
398
|
+
* The result carries a content-free fencing receipt.
|
|
399
|
+
*/
|
|
400
|
+
recall(input: GnoRecallInput): Promise<GnoRecallResult>;
|
|
373
401
|
createNote(options: GnoCreateNoteOptions): Promise<GnoCreateNoteResult>;
|
|
374
402
|
createFolder(options: GnoCreateFolderOptions): Promise<GnoCreateFolderResult>;
|
|
375
403
|
previewRenameNote(
|
package/src/serve/routes/api.ts
CHANGED
|
@@ -38,6 +38,7 @@ import type { StartJobError } from "../jobs";
|
|
|
38
38
|
import type { ResidentStatus } from "../status-model";
|
|
39
39
|
import type { CollectionWatchService } from "../watch-service";
|
|
40
40
|
|
|
41
|
+
import { getIndexDbPath } from "../../app/constants";
|
|
41
42
|
import { buildVerifiedAsk } from "../../app/verified-ask";
|
|
42
43
|
import { modelsPull } from "../../cli/commands/models/pull";
|
|
43
44
|
import {
|
|
@@ -80,6 +81,13 @@ import {
|
|
|
80
81
|
planCreateFolder,
|
|
81
82
|
planDuplicateRefactor,
|
|
82
83
|
} from "../../core/file-refactors";
|
|
84
|
+
import {
|
|
85
|
+
MemoryError,
|
|
86
|
+
type MemoryErrorCode,
|
|
87
|
+
MemoryService,
|
|
88
|
+
type RecallInput,
|
|
89
|
+
type RememberInput,
|
|
90
|
+
} from "../../core/memory";
|
|
83
91
|
import {
|
|
84
92
|
hasContentMutation,
|
|
85
93
|
recordContentMutation,
|
|
@@ -117,6 +125,7 @@ import {
|
|
|
117
125
|
validateTag,
|
|
118
126
|
} from "../../core/tags";
|
|
119
127
|
import { validateRelPath } from "../../core/validation";
|
|
128
|
+
import { writeLeasePath } from "../../core/write-lease";
|
|
120
129
|
import {
|
|
121
130
|
defaultSyncService,
|
|
122
131
|
type SyncResult,
|
|
@@ -534,6 +543,12 @@ export interface CreateEditableCopyRequestBody {
|
|
|
534
543
|
uri?: string;
|
|
535
544
|
}
|
|
536
545
|
|
|
546
|
+
/** POST /api/memory/remember body: the shared core contract, verbatim. */
|
|
547
|
+
export interface MemoryRememberRequestBody extends RememberInput {}
|
|
548
|
+
|
|
549
|
+
/** POST /api/memory/recall body: the shared core contract, verbatim. */
|
|
550
|
+
export interface MemoryRecallRequestBody extends RecallInput {}
|
|
551
|
+
|
|
537
552
|
export interface PublishExportRequestBody {
|
|
538
553
|
encryptionPassphrase?: string;
|
|
539
554
|
slug?: string;
|
|
@@ -3673,6 +3688,158 @@ export async function handleCreateCapture(
|
|
|
3673
3688
|
}
|
|
3674
3689
|
}
|
|
3675
3690
|
|
|
3691
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3692
|
+
// Memory (remember / recall)
|
|
3693
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
3694
|
+
|
|
3695
|
+
const HTTP_CREATED = 201;
|
|
3696
|
+
const HTTP_NOT_FOUND = 404;
|
|
3697
|
+
const HTTP_CONFLICT = 409;
|
|
3698
|
+
const HTTP_INTERNAL = 500;
|
|
3699
|
+
|
|
3700
|
+
/** HTTP status per stable memory error code; the code itself is the wire code. */
|
|
3701
|
+
const MEMORY_ERROR_STATUS: Readonly<Record<MemoryErrorCode, number>> = {
|
|
3702
|
+
MEMORY_TEXT_REQUIRED: 400,
|
|
3703
|
+
MEMORY_TEXT_TOO_LARGE: 400,
|
|
3704
|
+
MEMORY_QUERY_REQUIRED: 400,
|
|
3705
|
+
MEMORY_BUDGET_INVALID: 400,
|
|
3706
|
+
MEMORY_COLLECTION_REQUIRED: 400,
|
|
3707
|
+
MEMORY_COLLECTION_NOT_FOUND: HTTP_NOT_FOUND,
|
|
3708
|
+
MEMORY_COLLECTION_UNMANAGED: 400,
|
|
3709
|
+
MEMORY_SCOPES_REQUIRED: 400,
|
|
3710
|
+
MEMORY_SCOPES_INVALID: 400,
|
|
3711
|
+
MEMORY_IDENTITY_REQUIRED: 400,
|
|
3712
|
+
MEMORY_DECISION_INVALID: 400,
|
|
3713
|
+
MEMORY_PREDECESSOR_REQUIRED: 400,
|
|
3714
|
+
MEMORY_PREDECESSOR_NOT_FOUND: HTTP_NOT_FOUND,
|
|
3715
|
+
MEMORY_PREDECESSOR_HASH_MISMATCH: HTTP_CONFLICT,
|
|
3716
|
+
MEMORY_SUPERSEDE_CONFLICT: HTTP_CONFLICT,
|
|
3717
|
+
MEMORY_FENCED_REPLAY: 400,
|
|
3718
|
+
MEMORY_FENCED_DERIVED: 400,
|
|
3719
|
+
MEMORY_WRITE_LEASE_BUSY: HTTP_CONFLICT,
|
|
3720
|
+
MEMORY_SYNC_FAILED: HTTP_INTERNAL,
|
|
3721
|
+
MEMORY_SUPERSEDE_PROJECTION_FAILED: HTTP_INTERNAL,
|
|
3722
|
+
MEMORY_QUERY_FAILED: HTTP_INTERNAL,
|
|
3723
|
+
};
|
|
3724
|
+
|
|
3725
|
+
export interface MemoryRouteDeps {
|
|
3726
|
+
/** Shared `.mcp-write.lock` path; defaults to the resident index's lease. */
|
|
3727
|
+
lockPath?: string;
|
|
3728
|
+
lockWaitMs?: number;
|
|
3729
|
+
}
|
|
3730
|
+
|
|
3731
|
+
function memoryErrorResponse(error: unknown, fallback: string): Response {
|
|
3732
|
+
if (error instanceof MemoryError) {
|
|
3733
|
+
return errorResponse(
|
|
3734
|
+
error.code,
|
|
3735
|
+
error.message,
|
|
3736
|
+
MEMORY_ERROR_STATUS[error.code]
|
|
3737
|
+
);
|
|
3738
|
+
}
|
|
3739
|
+
return errorResponse(
|
|
3740
|
+
"RUNTIME",
|
|
3741
|
+
`${fallback}: ${error instanceof Error ? error.message : String(error)}`,
|
|
3742
|
+
HTTP_INTERNAL
|
|
3743
|
+
);
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
async function readMemoryBody(
|
|
3747
|
+
req: Request
|
|
3748
|
+
): Promise<
|
|
3749
|
+
| { ok: true; body: Record<string, unknown> }
|
|
3750
|
+
| { ok: false; response: Response }
|
|
3751
|
+
> {
|
|
3752
|
+
let body: unknown;
|
|
3753
|
+
try {
|
|
3754
|
+
body = await req.json();
|
|
3755
|
+
} catch {
|
|
3756
|
+
return {
|
|
3757
|
+
ok: false,
|
|
3758
|
+
response: errorResponse("VALIDATION", "Invalid JSON body"),
|
|
3759
|
+
};
|
|
3760
|
+
}
|
|
3761
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
3762
|
+
return {
|
|
3763
|
+
ok: false,
|
|
3764
|
+
response: errorResponse(
|
|
3765
|
+
"VALIDATION",
|
|
3766
|
+
"Request body must be a JSON object"
|
|
3767
|
+
),
|
|
3768
|
+
};
|
|
3769
|
+
}
|
|
3770
|
+
return { ok: true, body: body as Record<string, unknown> };
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
/**
|
|
3774
|
+
* The service owns the shared write lease; this adapter never takes it.
|
|
3775
|
+
* Semantic matching/retrieval is enabled only when the resident context has
|
|
3776
|
+
* an embedding port (and a vector index for recall); otherwise the service
|
|
3777
|
+
* reports lexical-only mode in the result.
|
|
3778
|
+
*/
|
|
3779
|
+
function createMemoryService(
|
|
3780
|
+
ctxHolder: ContextHolder,
|
|
3781
|
+
store: SqliteAdapter,
|
|
3782
|
+
deps: MemoryRouteDeps
|
|
3783
|
+
): MemoryService {
|
|
3784
|
+
const ctx = ctxHolder.current;
|
|
3785
|
+
return new MemoryService({
|
|
3786
|
+
store,
|
|
3787
|
+
config: ctx.config,
|
|
3788
|
+
collections: ctx.config.collections,
|
|
3789
|
+
lockPath: deps.lockPath ?? writeLeasePath(getIndexDbPath(ctx.indexName)),
|
|
3790
|
+
lockWaitMs: deps.lockWaitMs,
|
|
3791
|
+
embedPort: ctx.embedPort,
|
|
3792
|
+
vectorIndex: ctx.vectorIndex,
|
|
3793
|
+
});
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
/**
|
|
3797
|
+
* POST /api/memory/remember
|
|
3798
|
+
* Store a fact (or propose candidates) in a memory-managed collection.
|
|
3799
|
+
* Returns 201 when a record was written, 200 otherwise.
|
|
3800
|
+
*/
|
|
3801
|
+
export async function handleMemoryRemember(
|
|
3802
|
+
ctxHolder: ContextHolder,
|
|
3803
|
+
store: SqliteAdapter,
|
|
3804
|
+
req: Request,
|
|
3805
|
+
deps: MemoryRouteDeps = {}
|
|
3806
|
+
): Promise<Response> {
|
|
3807
|
+
const parsed = await readMemoryBody(req);
|
|
3808
|
+
if (!parsed.ok) return parsed.response;
|
|
3809
|
+
try {
|
|
3810
|
+
const result = await createMemoryService(ctxHolder, store, deps).remember(
|
|
3811
|
+
parsed.body as unknown as MemoryRememberRequestBody
|
|
3812
|
+
);
|
|
3813
|
+
const wrote = result.outcome === "added" || result.outcome === "superseded";
|
|
3814
|
+
if (wrote) ctxHolder.markContentMutation?.();
|
|
3815
|
+
return jsonResponse(result, wrote ? HTTP_CREATED : 200);
|
|
3816
|
+
} catch (error) {
|
|
3817
|
+
return memoryErrorResponse(error, "Failed to remember");
|
|
3818
|
+
}
|
|
3819
|
+
}
|
|
3820
|
+
|
|
3821
|
+
/**
|
|
3822
|
+
* POST /api/memory/recall
|
|
3823
|
+
* Budgeted, cited recall of current facts in the caller's explicit scopes.
|
|
3824
|
+
*/
|
|
3825
|
+
export async function handleMemoryRecall(
|
|
3826
|
+
ctxHolder: ContextHolder,
|
|
3827
|
+
store: SqliteAdapter,
|
|
3828
|
+
req: Request,
|
|
3829
|
+
deps: MemoryRouteDeps = {}
|
|
3830
|
+
): Promise<Response> {
|
|
3831
|
+
const parsed = await readMemoryBody(req);
|
|
3832
|
+
if (!parsed.ok) return parsed.response;
|
|
3833
|
+
try {
|
|
3834
|
+
const result = await createMemoryService(ctxHolder, store, deps).recall(
|
|
3835
|
+
parsed.body as unknown as MemoryRecallRequestBody
|
|
3836
|
+
);
|
|
3837
|
+
return jsonResponse(result);
|
|
3838
|
+
} catch (error) {
|
|
3839
|
+
return memoryErrorResponse(error, "Failed to recall");
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3676
3843
|
/**
|
|
3677
3844
|
* POST /api/docs
|
|
3678
3845
|
* Create a new document in a collection.
|
package/src/serve/server.ts
CHANGED
|
@@ -59,6 +59,8 @@ import {
|
|
|
59
59
|
handleHealth,
|
|
60
60
|
handleImportPreview,
|
|
61
61
|
handleInstallConnector,
|
|
62
|
+
handleMemoryRecall,
|
|
63
|
+
handleMemoryRemember,
|
|
62
64
|
handleMoveDoc,
|
|
63
65
|
handleNotePresets,
|
|
64
66
|
handleJob,
|
|
@@ -828,6 +830,30 @@ export async function startServer(
|
|
|
828
830
|
);
|
|
829
831
|
},
|
|
830
832
|
},
|
|
833
|
+
"/api/memory/remember": {
|
|
834
|
+
POST: async (req: Request) => {
|
|
835
|
+
if (!isRequestAllowed(req, port)) {
|
|
836
|
+
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
837
|
+
}
|
|
838
|
+
return withSecurityHeaders(
|
|
839
|
+
await handleMemoryRemember(ctxHolder, store, req),
|
|
840
|
+
isDev
|
|
841
|
+
);
|
|
842
|
+
},
|
|
843
|
+
},
|
|
844
|
+
"/api/memory/recall": {
|
|
845
|
+
POST: async (req: Request) => {
|
|
846
|
+
if (!isRequestAllowed(req, port)) {
|
|
847
|
+
return withSecurityHeaders(forbiddenResponse(), isDev);
|
|
848
|
+
}
|
|
849
|
+
return withSecurityHeaders(
|
|
850
|
+
await handleResidentRead(runtime as ResidentRuntime, req, () =>
|
|
851
|
+
handleMemoryRecall(ctxHolder, store, req)
|
|
852
|
+
),
|
|
853
|
+
isDev
|
|
854
|
+
);
|
|
855
|
+
},
|
|
856
|
+
},
|
|
831
857
|
"/api/docs": {
|
|
832
858
|
GET: async (req: Request) => {
|
|
833
859
|
const url = new URL(req.url);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migration: indexed memory scopes for managed memory records.
|
|
3
|
+
*
|
|
4
|
+
* Scopes are filterable inside retrieval queries (never post-hoc over a
|
|
5
|
+
* bounded candidate window), so they live in their own indexed table.
|
|
6
|
+
*
|
|
7
|
+
* @module src/store/migrations/027-memory-scopes
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Database } from "bun:sqlite";
|
|
11
|
+
|
|
12
|
+
import type { Migration } from "./runner";
|
|
13
|
+
|
|
14
|
+
export const migration: Migration = {
|
|
15
|
+
version: 27,
|
|
16
|
+
name: "memory_scopes",
|
|
17
|
+
|
|
18
|
+
up(db: Database): void {
|
|
19
|
+
db.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS doc_memory_scopes (
|
|
21
|
+
document_id INTEGER NOT NULL,
|
|
22
|
+
scope TEXT NOT NULL,
|
|
23
|
+
PRIMARY KEY (document_id, scope),
|
|
24
|
+
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
|
|
25
|
+
)
|
|
26
|
+
`);
|
|
27
|
+
db.exec(`
|
|
28
|
+
CREATE INDEX IF NOT EXISTS idx_doc_memory_scopes_scope
|
|
29
|
+
ON doc_memory_scopes(scope, document_id)
|
|
30
|
+
`);
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
down(db: Database): void {
|
|
34
|
+
db.exec("DROP INDEX IF EXISTS idx_doc_memory_scopes_scope");
|
|
35
|
+
db.exec("DROP TABLE IF EXISTS doc_memory_scopes");
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -40,6 +40,7 @@ import { migration as m023 } from "./023-collection-egress-policy";
|
|
|
40
40
|
import { migration as m024 } from "./024-egress-derived-lineage";
|
|
41
41
|
import { migration as m025 } from "./025-collection-egress-policy-revision";
|
|
42
42
|
import { migration as m026 } from "./026-file-refactor-recovery-journal";
|
|
43
|
+
import { migration as m027 } from "./027-memory-scopes";
|
|
43
44
|
|
|
44
45
|
/** All migrations in order */
|
|
45
46
|
export const migrations = [
|
|
@@ -69,4 +70,5 @@ export const migrations = [
|
|
|
69
70
|
m024,
|
|
70
71
|
m025,
|
|
71
72
|
m026,
|
|
73
|
+
m027,
|
|
72
74
|
];
|
|
@@ -75,6 +75,8 @@ import type {
|
|
|
75
75
|
IndexStatus,
|
|
76
76
|
IngestErrorInput,
|
|
77
77
|
IngestErrorRow,
|
|
78
|
+
MemoryEligibleDocument,
|
|
79
|
+
MemoryEligibleDocumentsOptions,
|
|
78
80
|
MigrationResult,
|
|
79
81
|
RetrievalTraceAppendResult,
|
|
80
82
|
RetrievalTraceBundle,
|
|
@@ -324,6 +326,13 @@ type FtsQueryBuildResult =
|
|
|
324
326
|
| { ok: true; query: string }
|
|
325
327
|
| { ok: false; error: string };
|
|
326
328
|
|
|
329
|
+
/**
|
|
330
|
+
* SQL fragment excluding documents superseded by an active document via the
|
|
331
|
+
* typed `supersedes` edge. `docIdExpr` names the candidate document id column.
|
|
332
|
+
*/
|
|
333
|
+
const SUPERSEDED_EXCLUSION_SQL = (docIdExpr: string): string =>
|
|
334
|
+
`AND NOT EXISTS (SELECT 1 FROM doc_edges se JOIN documents sd ON sd.id = se.src_doc_id AND sd.active = 1 WHERE se.dst_doc_id = ${docIdExpr} AND se.edge_type = 'supersedes')`;
|
|
335
|
+
|
|
327
336
|
/**
|
|
328
337
|
* Narrow lexical grammar for BM25/FTS queries.
|
|
329
338
|
*
|
|
@@ -333,7 +342,10 @@ type FtsQueryBuildResult =
|
|
|
333
342
|
* - negation with at least one positive term
|
|
334
343
|
* - hyphenated compounds handled intentionally
|
|
335
344
|
*/
|
|
336
|
-
function buildFts5Query(
|
|
345
|
+
function buildFts5Query(
|
|
346
|
+
query: string,
|
|
347
|
+
options: { anyTerm?: boolean } = {}
|
|
348
|
+
): FtsQueryBuildResult {
|
|
337
349
|
const trimmed = query.trim();
|
|
338
350
|
if (!trimmed) {
|
|
339
351
|
return { ok: false, error: "Search query cannot be empty" };
|
|
@@ -454,7 +466,9 @@ function buildFts5Query(query: string): FtsQueryBuildResult {
|
|
|
454
466
|
};
|
|
455
467
|
}
|
|
456
468
|
|
|
457
|
-
let ftsQuery =
|
|
469
|
+
let ftsQuery = options.anyTerm
|
|
470
|
+
? `(${positive.join(" OR ")})`
|
|
471
|
+
: positive.join(" AND ");
|
|
458
472
|
for (const negation of negative) {
|
|
459
473
|
ftsQuery = `${ftsQuery} NOT ${negation}`;
|
|
460
474
|
}
|
|
@@ -2635,7 +2649,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2635
2649
|
try {
|
|
2636
2650
|
const db = this.ensureOpen();
|
|
2637
2651
|
const limit = options.limit ?? 20;
|
|
2638
|
-
const builtQuery = buildFts5Query(query);
|
|
2652
|
+
const builtQuery = buildFts5Query(query, { anyTerm: options.anyTerm });
|
|
2639
2653
|
if (!builtQuery.ok) {
|
|
2640
2654
|
return err("INVALID_INPUT", builtQuery.error);
|
|
2641
2655
|
}
|
|
@@ -2683,6 +2697,21 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2683
2697
|
params.push(`%${options.author.toLowerCase()}%`);
|
|
2684
2698
|
}
|
|
2685
2699
|
|
|
2700
|
+
// Scope and supersession filters run inside the candidate subquery so
|
|
2701
|
+
// they narrow the corpus before the FTS LIMIT (never a post-filter).
|
|
2702
|
+
const innerConditions: string[] = [];
|
|
2703
|
+
const innerParams: string[] = [];
|
|
2704
|
+
if (options.memoryScopesAny && options.memoryScopesAny.length > 0) {
|
|
2705
|
+
const placeholders = options.memoryScopesAny.map(() => "?").join(",");
|
|
2706
|
+
innerConditions.push(
|
|
2707
|
+
`AND EXISTS (SELECT 1 FROM doc_memory_scopes ms WHERE ms.document_id = documents.id AND ms.scope IN (${placeholders}))`
|
|
2708
|
+
);
|
|
2709
|
+
innerParams.push(...options.memoryScopesAny);
|
|
2710
|
+
}
|
|
2711
|
+
if (options.excludeSuperseded) {
|
|
2712
|
+
innerConditions.push(SUPERSEDED_EXCLUSION_SQL("documents.id"));
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2686
2715
|
const hasOuterFilters = tagConditions.length > 0;
|
|
2687
2716
|
const ftsLimit = hasOuterFilters ? limit * 10 : limit;
|
|
2688
2717
|
params.push(limit);
|
|
@@ -2711,6 +2740,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2711
2740
|
? "AND (COALESCE(NULLIF(record_source_path, ''), rel_path) = ? OR substr(COALESCE(NULLIF(record_source_path, ''), rel_path), 1, length(?) + 1) = ? || '/')"
|
|
2712
2741
|
: ""
|
|
2713
2742
|
}
|
|
2743
|
+
${innerConditions.join("\n ")}
|
|
2714
2744
|
)
|
|
2715
2745
|
ORDER BY score
|
|
2716
2746
|
LIMIT ?
|
|
@@ -2789,6 +2819,7 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
2789
2819
|
options.relPathPrefix,
|
|
2790
2820
|
]
|
|
2791
2821
|
: []),
|
|
2822
|
+
...innerParams,
|
|
2792
2823
|
ftsLimit,
|
|
2793
2824
|
...params,
|
|
2794
2825
|
];
|
|
@@ -3076,6 +3107,99 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
|
|
|
3076
3107
|
}
|
|
3077
3108
|
}
|
|
3078
3109
|
|
|
3110
|
+
async setDocMemoryScopes(
|
|
3111
|
+
documentId: number,
|
|
3112
|
+
scopes: string[]
|
|
3113
|
+
): Promise<StoreResult<void>> {
|
|
3114
|
+
try {
|
|
3115
|
+
const db = this.ensureOpen();
|
|
3116
|
+
const transaction = db.transaction(() => {
|
|
3117
|
+
db.run("DELETE FROM doc_memory_scopes WHERE document_id = ?", [
|
|
3118
|
+
documentId,
|
|
3119
|
+
]);
|
|
3120
|
+
if (scopes.length > 0) {
|
|
3121
|
+
const stmt = db.prepare(
|
|
3122
|
+
"INSERT OR IGNORE INTO doc_memory_scopes (document_id, scope) VALUES (?, ?)"
|
|
3123
|
+
);
|
|
3124
|
+
for (const scope of scopes) {
|
|
3125
|
+
stmt.run(documentId, scope);
|
|
3126
|
+
}
|
|
3127
|
+
}
|
|
3128
|
+
});
|
|
3129
|
+
transaction();
|
|
3130
|
+
return ok(undefined);
|
|
3131
|
+
} catch (cause) {
|
|
3132
|
+
return err(
|
|
3133
|
+
"QUERY_FAILED",
|
|
3134
|
+
cause instanceof Error ? cause.message : "Failed to set memory scopes",
|
|
3135
|
+
cause
|
|
3136
|
+
);
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
async getDocMemoryScopes(documentId: number): Promise<StoreResult<string[]>> {
|
|
3141
|
+
try {
|
|
3142
|
+
const db = this.ensureOpen();
|
|
3143
|
+
const rows = db
|
|
3144
|
+
.query<{ scope: string }, [number]>(
|
|
3145
|
+
"SELECT scope FROM doc_memory_scopes WHERE document_id = ? ORDER BY scope"
|
|
3146
|
+
)
|
|
3147
|
+
.all(documentId);
|
|
3148
|
+
return ok(rows.map((row) => row.scope));
|
|
3149
|
+
} catch (cause) {
|
|
3150
|
+
return err(
|
|
3151
|
+
"QUERY_FAILED",
|
|
3152
|
+
cause instanceof Error ? cause.message : "Failed to read memory scopes",
|
|
3153
|
+
cause
|
|
3154
|
+
);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
async listMemoryEligibleDocuments(
|
|
3159
|
+
options: MemoryEligibleDocumentsOptions
|
|
3160
|
+
): Promise<StoreResult<MemoryEligibleDocument[]>> {
|
|
3161
|
+
try {
|
|
3162
|
+
const db = this.ensureOpen();
|
|
3163
|
+
if (options.scopes.length === 0) {
|
|
3164
|
+
return ok([]);
|
|
3165
|
+
}
|
|
3166
|
+
const placeholders = options.scopes.map(() => "?").join(",");
|
|
3167
|
+
const rows = db
|
|
3168
|
+
.query<
|
|
3169
|
+
{ id: number; docid: string; uri: string; mirror_hash: string },
|
|
3170
|
+
string[]
|
|
3171
|
+
>(
|
|
3172
|
+
`
|
|
3173
|
+
SELECT d.id, d.docid, d.uri, d.mirror_hash
|
|
3174
|
+
FROM documents d
|
|
3175
|
+
WHERE d.active = 1
|
|
3176
|
+
AND d.collection = ?
|
|
3177
|
+
AND d.mirror_hash IS NOT NULL
|
|
3178
|
+
AND EXISTS (SELECT 1 FROM doc_memory_scopes ms WHERE ms.document_id = d.id AND ms.scope IN (${placeholders}))
|
|
3179
|
+
${options.excludeSuperseded ? SUPERSEDED_EXCLUSION_SQL("d.id") : ""}
|
|
3180
|
+
ORDER BY d.id
|
|
3181
|
+
`
|
|
3182
|
+
)
|
|
3183
|
+
.all(options.collection, ...options.scopes);
|
|
3184
|
+
return ok(
|
|
3185
|
+
rows.map((row) => ({
|
|
3186
|
+
id: row.id,
|
|
3187
|
+
docid: row.docid,
|
|
3188
|
+
uri: row.uri,
|
|
3189
|
+
mirrorHash: row.mirror_hash,
|
|
3190
|
+
}))
|
|
3191
|
+
);
|
|
3192
|
+
} catch (cause) {
|
|
3193
|
+
return err(
|
|
3194
|
+
"QUERY_FAILED",
|
|
3195
|
+
cause instanceof Error
|
|
3196
|
+
? cause.message
|
|
3197
|
+
: "Failed to list memory-eligible documents",
|
|
3198
|
+
cause
|
|
3199
|
+
);
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
|
|
3079
3203
|
/**
|
|
3080
3204
|
* Get all tags for a document.
|
|
3081
3205
|
*/
|