1- import { randomUUID } from 'node:crypto' ;
1+ import { createHash , randomUUID } from 'node:crypto' ;
22import { chmodSync , mkdirSync } from 'node:fs' ;
33import { join } from 'node:path' ;
44import { DatabaseSync } from 'node:sqlite' ;
@@ -19,6 +19,14 @@ export const attachmentDirectory = (connectionId: string) =>
1919export const DREAM_INTERVAL_MS = 21_600_000 ;
2020export const learningDatabase = ( connectionId : string ) =>
2121 `learning-${ encodeURIComponent ( connectionId ) } .sqlite` ;
22+ const learningProgressDirectory = ( directory : string ) => `${ directory } .progress` ;
23+
24+ export function learningProgressDatabase ( input : { directory : string ; config : PluginConfig } ) {
25+ const scope = createHash ( 'sha256' )
26+ . update ( JSON . stringify ( [ input . config . serverUrl , input . config . agentId ] ) )
27+ . digest ( 'hex' ) ;
28+ return join ( learningProgressDirectory ( input . directory ) , `${ scope } .sqlite` ) ;
29+ }
2230
2331export type LearningJob = {
2432 id : string ;
@@ -32,7 +40,7 @@ export type LearningJob = {
3240 startedAt : number ;
3341} ;
3442
35- /** A temporary delivery queue , not an alternative personal memory store. */
43+ /** Temporary evidence with durable completion receipts , not an alternative personal memory store. */
3644export class LearningStore {
3745 private readonly db : DatabaseSync ;
3846
@@ -73,19 +81,53 @@ export class LearningStore {
7381 this . db . close ( ) ;
7482 throw new Error ( 'Context Use learning queue belongs to a different connection.' ) ;
7583 }
84+ // Receipts belong to the destination and agent, independently of OAuth credentials
85+ // and the private connection directory removed during uninstall.
86+ try {
87+ mkdirSync ( learningProgressDirectory ( input . directory ) , {
88+ recursive : true ,
89+ mode : PRIVATE_DIRECTORY_MODE ,
90+ } ) ;
91+ const progressPath = learningProgressDatabase ( input ) ;
92+ this . db . prepare ( 'ATTACH DATABASE ? AS progress' ) . run ( progressPath ) ;
93+ chmodSync ( progressPath , PRIVATE_FILE_MODE ) ;
94+ this . db . exec ( `
95+ CREATE TABLE IF NOT EXISTS progress.completed (
96+ source TEXT NOT NULL, fingerprint TEXT NOT NULL, PRIMARY KEY (source, fingerprint)
97+ );
98+ CREATE TABLE IF NOT EXISTS progress.attachments (
99+ source TEXT NOT NULL, key TEXT NOT NULL, asset TEXT, uploadName TEXT,
100+ PRIMARY KEY (source, key)
101+ );
102+ INSERT OR IGNORE INTO progress.completed
103+ SELECT source, fingerprint FROM evidence WHERE text IS NULL;
104+ INSERT OR IGNORE INTO progress.attachments
105+ SELECT source, key, asset, uploadName FROM attachments
106+ WHERE asset IS NOT NULL OR uploadName IS NOT NULL;
107+ ` ) ;
108+ } catch ( error ) {
109+ this . db . close ( ) ;
110+ throw error ;
111+ }
76112 }
77113
78114 capture ( input : { source : string ; evidence : LearningEvidence [ ] ; now : number } ) : void {
79115 this . transaction ( ( ) => {
80116 const insert = this . db . prepare (
81- 'INSERT OR IGNORE INTO evidence(source, fingerprint, text, capturedAt) VALUES (?, ?, ?, ?)' ,
117+ `INSERT OR IGNORE INTO evidence(source, fingerprint, text, capturedAt)
118+ SELECT ?, ?, ?, ? WHERE NOT EXISTS (
119+ SELECT 1 FROM progress.completed WHERE source = ? AND fingerprint = ?
120+ )` ,
82121 ) ;
83122 for ( const item of input . evidence ) {
84- insert . run ( input . source , item . key , item . text , input . now ) ;
123+ insert . run ( input . source , item . key , item . text , input . now , input . source , item . key ) ;
85124 if ( item . attachment ) {
86125 this . db
87- . prepare ( `INSERT OR IGNORE INTO attachments(source, key, evidenceId, data)
88- SELECT source, fingerprint, id, ? FROM evidence WHERE source = ? AND fingerprint = ?` )
126+ . prepare ( `INSERT OR IGNORE INTO attachments(source, key, evidenceId, data, asset, uploadName)
127+ SELECT e.source, e.fingerprint, e.id, ?, p.asset, p.uploadName
128+ FROM evidence e LEFT JOIN progress.attachments p
129+ ON p.source = e.source AND p.key = e.fingerprint
130+ WHERE e.source = ? AND e.fingerprint = ? AND e.text IS NOT NULL` )
89131 . run ( JSON . stringify ( item . attachment ) , input . source , item . key ) ;
90132 }
91133 }
@@ -209,30 +251,46 @@ export class LearningStore {
209251
210252 prepareAttachmentUpload ( input : { jobId : string ; key : string ; name : string } ) : string {
211253 const source = this . attachmentSource ( input ) ;
212- this . db
213- . prepare (
214- 'UPDATE attachments SET uploadName = COALESCE(uploadName, ?) WHERE source = ? AND key = ?' ,
215- )
216- . run ( input . name , source , input . key ) ;
217- return (
254+ return this . transaction ( ( ) => {
218255 this . db
219- . prepare ( 'SELECT uploadName FROM attachments WHERE source = ? AND key = ?' )
220- . get ( source , input . key ) as { uploadName : string }
221- ) . uploadName ;
256+ . prepare (
257+ 'UPDATE attachments SET uploadName = COALESCE(uploadName, ?) WHERE source = ? AND key = ?' ,
258+ )
259+ . run ( input . name , source , input . key ) ;
260+ this . preserveAttachment ( { source, key : input . key } ) ;
261+ return (
262+ this . db
263+ . prepare ( 'SELECT uploadName FROM attachments WHERE source = ? AND key = ?' )
264+ . get ( source , input . key ) as { uploadName : string }
265+ ) . uploadName ;
266+ } ) ;
222267 }
223268
224269 clearAttachmentUploadName ( input : { jobId : string ; key : string } ) : void {
225270 const source = this . attachmentSource ( input ) ;
226- this . db
227- . prepare ( 'UPDATE attachments SET uploadName = NULL WHERE source = ? AND key = ?' )
228- . run ( source , input . key ) ;
271+ this . transaction ( ( ) => {
272+ this . db
273+ . prepare ( 'UPDATE attachments SET uploadName = NULL WHERE source = ? AND key = ?' )
274+ . run ( source , input . key ) ;
275+ this . preserveAttachment ( { source, key : input . key } ) ;
276+ } ) ;
229277 }
230278
231279 savedAttachment ( input : { jobId : string ; key : string ; asset : string } ) : void {
232280 const source = this . attachmentSource ( input ) ;
281+ this . transaction ( ( ) => {
282+ this . db
283+ . prepare ( 'UPDATE attachments SET asset = ? WHERE source = ? AND key = ?' )
284+ . run ( input . asset , source , input . key ) ;
285+ this . preserveAttachment ( { source, key : input . key } ) ;
286+ } ) ;
287+ }
288+
289+ private preserveAttachment ( input : { source : string ; key : string } ) : void {
233290 this . db
234- . prepare ( 'UPDATE attachments SET asset = ? WHERE source = ? AND key = ?' )
235- . run ( input . asset , source , input . key ) ;
291+ . prepare ( `INSERT OR REPLACE INTO progress.attachments
292+ SELECT source, key, asset, uploadName FROM attachments WHERE source = ? AND key = ?` )
293+ . run ( input . source , input . key ) ;
236294 }
237295
238296 acknowledge ( input : { sessionKey : string ; omittedAttachments ?: string [ ] } ) : void {
@@ -272,6 +330,10 @@ export class LearningStore {
272330 throw new Error ( 'Learning must be acknowledged before removing its evidence.' ) ;
273331 }
274332 if ( job . kind === 'learn' ) {
333+ this . db
334+ . prepare ( `INSERT OR IGNORE INTO progress.completed
335+ SELECT source, fingerprint FROM evidence WHERE source = ? AND id <= ?` )
336+ . run ( job . source , job . through ) ;
275337 this . db
276338 . prepare ( 'UPDATE evidence SET text = NULL WHERE source = ? AND id <= ?' )
277339 . run ( job . source , job . through ) ;
0 commit comments