@remnic/core 9.3.688 → 9.3.690
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/dist/access-boundary.d.ts +5 -4
- package/dist/access-boundary.js +12 -11
- package/dist/access-cli.js +90 -26
- package/dist/access-cli.js.map +1 -1
- package/dist/access-http.d.ts +4 -3
- package/dist/access-http.js +15 -14
- package/dist/access-mcp.d.ts +11 -3
- package/dist/access-mcp.js +14 -13
- package/dist/access-operations.d.ts +11 -5
- package/dist/access-operations.js +15 -12
- package/dist/{access-service-DmCHJ4cH.d.ts → access-service-Dujr3MTm.d.ts} +62 -2
- package/dist/access-service.d.ts +4 -3
- package/dist/access-service.js +11 -10
- package/dist/access-surface-catalog.d.ts +4 -3
- package/dist/access-surface-catalog.js +2 -0
- package/dist/access-surface-catalog.js.map +1 -1
- package/dist/bootstrap.d.ts +3 -2
- package/dist/briefing.d.ts +1 -0
- package/dist/briefing.js +3 -2
- package/dist/buffer.d.ts +1 -0
- package/dist/{catalog-COqWZlZ6.d.ts → catalog-CKPtJ114.d.ts} +1 -1
- package/dist/causal-consolidation.js +4 -3
- package/dist/causal-consolidation.js.map +1 -1
- package/dist/{chunk-B4XVLHJA.js → chunk-2N6UNJSX.js} +2 -2
- package/dist/{chunk-NN7QYW5W.js → chunk-46URPRE6.js} +2 -2
- package/dist/chunk-46URPRE6.js.map +1 -0
- package/dist/{chunk-4N3TFFPH.js → chunk-54PVJDO5.js} +2 -2
- package/dist/{chunk-PH3HOKYW.js → chunk-67MMWC74.js} +2 -2
- package/dist/{chunk-STOEE37X.js → chunk-BKAMHZYR.js} +2 -2
- package/dist/{chunk-B43NZNMG.js → chunk-CE6CBRCV.js} +432 -2240
- package/dist/chunk-CE6CBRCV.js.map +1 -0
- package/dist/{chunk-RVYD6LR3.js → chunk-CP2NZQLT.js} +15 -4
- package/dist/chunk-CP2NZQLT.js.map +1 -0
- package/dist/{chunk-BLIWOONZ.js → chunk-CUNTLEJP.js} +4 -12
- package/dist/chunk-CUNTLEJP.js.map +1 -0
- package/dist/{chunk-2SJCWLQD.js → chunk-CWE74HRG.js} +3 -3
- package/dist/{chunk-VX6OBUDW.js → chunk-GR77Z2BM.js} +2 -2
- package/dist/{chunk-CTOQEZSN.js → chunk-LXIEXSHU.js} +2 -2
- package/dist/{chunk-KFBOZYME.js → chunk-NSVXK7M5.js} +33 -4
- package/dist/chunk-NSVXK7M5.js.map +1 -0
- package/dist/{chunk-473JIN2U.js → chunk-OMKUJTVJ.js} +37 -5
- package/dist/chunk-OMKUJTVJ.js.map +1 -0
- package/dist/{chunk-GS55WYRL.js → chunk-PHZKALOE.js} +2 -2
- package/dist/{chunk-6O6A6YUO.js → chunk-RGNEARXW.js} +3 -3
- package/dist/{chunk-4FE2K57M.js → chunk-RTFAZOIR.js} +2 -2
- package/dist/{chunk-UTYBJR7M.js → chunk-SB6CQTKP.js} +2 -2
- package/dist/chunk-SVOZFLIQ.js +16 -0
- package/dist/chunk-SVOZFLIQ.js.map +1 -0
- package/dist/{chunk-2KAYTPPT.js → chunk-TYF3D4MS.js} +5 -5
- package/dist/{chunk-WIHPNY65.js → chunk-UD5OKH4J.js} +2 -2
- package/dist/{chunk-QANVLERJ.js → chunk-UPIBE2DK.js} +2 -2
- package/dist/{chunk-CCWHPGT4.js → chunk-WROKD3XC.js} +45 -30
- package/dist/chunk-WROKD3XC.js.map +1 -0
- package/dist/{chunk-ZPTISBQU.js → chunk-X5J3JZR3.js} +7 -7
- package/dist/{chunk-OV4D5T7V.js → chunk-X7RLU5CR.js} +2 -2
- package/dist/{chunk-S2OU5DZY.js → chunk-XKUKJIOY.js} +30 -7
- package/dist/chunk-XKUKJIOY.js.map +1 -0
- package/dist/{chunk-FUCUR2OZ.js → chunk-Z7KILAOU.js} +603 -9
- package/dist/chunk-Z7KILAOU.js.map +1 -0
- package/dist/chunk-ZU7N3S6V.js +2190 -0
- package/dist/chunk-ZU7N3S6V.js.map +1 -0
- package/dist/{chunk-DCWIQFNA.js → chunk-ZUDM75KG.js} +4 -4
- package/dist/{cli-D8nZ2MPH.d.ts → cli-BkDp6WNi.d.ts} +2 -2
- package/dist/cli.d.ts +5 -4
- package/dist/cli.js +32 -30
- package/dist/compounding/engine.d.ts +1 -0
- package/dist/compounding/engine.js +3 -2
- package/dist/connectors/codex-materialize-runner.js +3 -2
- package/dist/connectors/index.js +3 -2
- package/dist/consolidation-provenance-check.d.ts +1 -0
- package/dist/consolidation-undo.d.ts +1 -0
- package/dist/contradiction/index.d.ts +1 -0
- package/dist/conversation-index/backend.js +2 -2
- package/dist/entity-retrieval.d.ts +1 -0
- package/dist/entity-retrieval.js +3 -2
- package/dist/explicit-capture.d.ts +3 -2
- package/dist/index.d.ts +6 -5
- package/dist/index.js +47 -44
- package/dist/index.js.map +1 -1
- package/dist/lcm/engine.js +3 -3
- package/dist/lcm/index.js +5 -5
- package/dist/maintenance/memory-governance.js +3 -2
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -2
- package/dist/maintenance/rebuild-memory-projection.js +4 -3
- package/dist/mcp-memory-inspector-app.d.ts +4 -3
- package/dist/memory-worth-outcomes.d.ts +1 -0
- package/dist/namespaces/migrate.d.ts +2 -1
- package/dist/namespaces/migrate.js +10 -9
- package/dist/namespaces/search.js +6 -6
- package/dist/namespaces/storage.d.ts +2 -1
- package/dist/namespaces/storage.js +3 -2
- package/dist/operator-toolkit.d.ts +1 -0
- package/dist/operator-toolkit.js +13 -11
- package/dist/{orchestrator-CA6ouzBn.d.ts → orchestrator-B7ixmUkP.d.ts} +145 -1
- package/dist/orchestrator.d.ts +3 -2
- package/dist/orchestrator.js +22 -20
- package/dist/recall-pipeline-stages.d.ts +167 -0
- package/dist/recall-pipeline-stages.js +61 -0
- package/dist/recall-pipeline-stages.js.map +1 -0
- package/dist/schemas.d.ts +10 -10
- package/dist/search/factory.js +5 -5
- package/dist/search/index.js +9 -9
- package/dist/semantic-consolidation.js +4 -3
- package/dist/semantic-rule-promotion.js +3 -2
- package/dist/semantic-rule-verifier.js +3 -2
- package/dist/storage.d.ts +3 -27
- package/dist/storage.js +5 -3
- package/dist/structured-attributes.d.ts +29 -0
- package/dist/structured-attributes.js +8 -0
- package/dist/structured-attributes.js.map +1 -0
- package/dist/temporal-supersession.d.ts +1 -0
- package/dist/tier-migration.d.ts +1 -0
- package/dist/verified-recall.js +3 -2
- package/package.json +2 -2
- package/src/access-boundary.ts +2 -1
- package/src/access-cli.test.ts +40 -0
- package/src/access-cli.ts +90 -2
- package/src/access-http.ts +35 -6
- package/src/access-mcp.ts +34 -0
- package/src/access-operations.ts +45 -0
- package/src/access-service.ts +60 -0
- package/src/access-surface-catalog.test.ts +1 -1
- package/src/access-surface-catalog.ts +2 -0
- package/src/cli.ts +18 -0
- package/src/coding/architecture-card.test.ts +544 -0
- package/src/coding/architecture-card.ts +687 -0
- package/src/coding/architecture-surfaces.test.ts +579 -0
- package/src/coding/architecture-surfaces.ts +457 -0
- package/src/maintenance/namespace-maintenance-fanout.test.ts +595 -0
- package/src/maintenance/namespace-maintenance-fanout.ts +318 -0
- package/src/maintenance/namespace-planner.ts +74 -16
- package/src/operator-toolkit.ts +25 -0
- package/src/orchestrator.ts +144 -0
- package/src/recall-pipeline-stages.test.ts +269 -0
- package/src/recall-pipeline-stages.ts +294 -0
- package/src/storage.ts +6 -20
- package/src/structured-attributes.ts +39 -0
- package/dist/chunk-473JIN2U.js.map +0 -1
- package/dist/chunk-B43NZNMG.js.map +0 -1
- package/dist/chunk-BLIWOONZ.js.map +0 -1
- package/dist/chunk-CCWHPGT4.js.map +0 -1
- package/dist/chunk-FUCUR2OZ.js.map +0 -1
- package/dist/chunk-KFBOZYME.js.map +0 -1
- package/dist/chunk-NN7QYW5W.js.map +0 -1
- package/dist/chunk-RVYD6LR3.js.map +0 -1
- package/dist/chunk-S2OU5DZY.js.map +0 -1
- /package/dist/{chunk-B4XVLHJA.js.map → chunk-2N6UNJSX.js.map} +0 -0
- /package/dist/{chunk-4N3TFFPH.js.map → chunk-54PVJDO5.js.map} +0 -0
- /package/dist/{chunk-PH3HOKYW.js.map → chunk-67MMWC74.js.map} +0 -0
- /package/dist/{chunk-STOEE37X.js.map → chunk-BKAMHZYR.js.map} +0 -0
- /package/dist/{chunk-2SJCWLQD.js.map → chunk-CWE74HRG.js.map} +0 -0
- /package/dist/{chunk-VX6OBUDW.js.map → chunk-GR77Z2BM.js.map} +0 -0
- /package/dist/{chunk-CTOQEZSN.js.map → chunk-LXIEXSHU.js.map} +0 -0
- /package/dist/{chunk-GS55WYRL.js.map → chunk-PHZKALOE.js.map} +0 -0
- /package/dist/{chunk-6O6A6YUO.js.map → chunk-RGNEARXW.js.map} +0 -0
- /package/dist/{chunk-4FE2K57M.js.map → chunk-RTFAZOIR.js.map} +0 -0
- /package/dist/{chunk-UTYBJR7M.js.map → chunk-SB6CQTKP.js.map} +0 -0
- /package/dist/{chunk-2KAYTPPT.js.map → chunk-TYF3D4MS.js.map} +0 -0
- /package/dist/{chunk-WIHPNY65.js.map → chunk-UD5OKH4J.js.map} +0 -0
- /package/dist/{chunk-QANVLERJ.js.map → chunk-UPIBE2DK.js.map} +0 -0
- /package/dist/{chunk-ZPTISBQU.js.map → chunk-X5J3JZR3.js.map} +0 -0
- /package/dist/{chunk-OV4D5T7V.js.map → chunk-X7RLU5CR.js.map} +0 -0
- /package/dist/{chunk-DCWIQFNA.js.map → chunk-ZUDM75KG.js.map} +0 -0
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
} from "./chunk-666A3MOW.js";
|
|
20
20
|
import {
|
|
21
21
|
LcmEngine
|
|
22
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-X5J3JZR3.js";
|
|
23
23
|
import {
|
|
24
24
|
chunkTranscriptEntries
|
|
25
25
|
} from "./chunk-TECVW3JP.js";
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import {
|
|
30
30
|
CompoundingEngine,
|
|
31
31
|
defaultTierMigrationCycleBudget
|
|
32
|
-
} from "./chunk-
|
|
32
|
+
} from "./chunk-BKAMHZYR.js";
|
|
33
33
|
import {
|
|
34
34
|
SharedContextManager
|
|
35
35
|
} from "./chunk-UFS7OXGL.js";
|
|
@@ -183,27 +183,27 @@ import {
|
|
|
183
183
|
buildEntityRecallSection,
|
|
184
184
|
entityRecentTranscriptLookbackHours,
|
|
185
185
|
readRecentEntityTranscriptEntries
|
|
186
|
-
} from "./chunk-
|
|
186
|
+
} from "./chunk-2N6UNJSX.js";
|
|
187
187
|
import {
|
|
188
188
|
formatDaySummaryMemories
|
|
189
189
|
} from "./chunk-EO5QWINU.js";
|
|
190
|
-
import {
|
|
191
|
-
normalizeReplaySessionKey
|
|
192
|
-
} from "./chunk-2PRQG7PV.js";
|
|
193
190
|
import {
|
|
194
191
|
RoutingRulesStore
|
|
195
192
|
} from "./chunk-WIWPSQYU.js";
|
|
193
|
+
import {
|
|
194
|
+
normalizeReplaySessionKey
|
|
195
|
+
} from "./chunk-2PRQG7PV.js";
|
|
196
196
|
import {
|
|
197
197
|
compareVerifiedEpisodeResults,
|
|
198
198
|
searchVerifiedEpisodes
|
|
199
|
-
} from "./chunk-
|
|
199
|
+
} from "./chunk-RTFAZOIR.js";
|
|
200
200
|
import {
|
|
201
201
|
ThreadingManager
|
|
202
202
|
} from "./chunk-QRDOSYOR.js";
|
|
203
203
|
import {
|
|
204
204
|
compareVerifiedSemanticRuleResults,
|
|
205
205
|
searchVerifiedSemanticRules
|
|
206
|
-
} from "./chunk-
|
|
206
|
+
} from "./chunk-67MMWC74.js";
|
|
207
207
|
import {
|
|
208
208
|
searchWorkProductLedgerEntries
|
|
209
209
|
} from "./chunk-ZRWB5D4H.js";
|
|
@@ -214,10 +214,15 @@ import {
|
|
|
214
214
|
PolicyRuntimeManager
|
|
215
215
|
} from "./chunk-33JBK2XP.js";
|
|
216
216
|
import {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
217
|
+
NamespaceCatalog,
|
|
218
|
+
planNamespaceMaintenance,
|
|
219
|
+
runNamespaceMaintenanceBatchPlan,
|
|
220
|
+
runNamespaceMaintenanceFanout,
|
|
221
|
+
summarizeNamespaceMaintenanceHealth
|
|
222
|
+
} from "./chunk-ZU7N3S6V.js";
|
|
223
|
+
import {
|
|
224
|
+
NamespaceStorageRouter
|
|
225
|
+
} from "./chunk-UD5OKH4J.js";
|
|
221
226
|
import {
|
|
222
227
|
isAboveImportanceThreshold,
|
|
223
228
|
scoreImportance
|
|
@@ -262,7 +267,7 @@ import {
|
|
|
262
267
|
materializeAfterSemanticConsolidation,
|
|
263
268
|
parseConsolidationResponse,
|
|
264
269
|
parseOperatorAwareConsolidationResponse
|
|
265
|
-
} from "./chunk-
|
|
270
|
+
} from "./chunk-PHZKALOE.js";
|
|
266
271
|
import {
|
|
267
272
|
buildBehaviorSignalsForMemory,
|
|
268
273
|
dedupeBehaviorSignalsByMemoryAndHash
|
|
@@ -327,11 +332,11 @@ import {
|
|
|
327
332
|
} from "./chunk-6HMYUWXR.js";
|
|
328
333
|
import {
|
|
329
334
|
NamespaceSearchRouter
|
|
330
|
-
} from "./chunk-
|
|
335
|
+
} from "./chunk-LXIEXSHU.js";
|
|
331
336
|
import {
|
|
332
337
|
createConversationIndexRuntime,
|
|
333
338
|
createSearchBackend
|
|
334
|
-
} from "./chunk-
|
|
339
|
+
} from "./chunk-TYF3D4MS.js";
|
|
335
340
|
import {
|
|
336
341
|
NoopSearchBackend
|
|
337
342
|
} from "./chunk-CYEPCZN5.js";
|
|
@@ -363,16 +368,15 @@ import {
|
|
|
363
368
|
} from "./chunk-YN4ZT4CW.js";
|
|
364
369
|
import {
|
|
365
370
|
buildChainFollowupGenerator
|
|
366
|
-
} from "./chunk-
|
|
371
|
+
} from "./chunk-X7RLU5CR.js";
|
|
367
372
|
import {
|
|
368
373
|
ContentHashIndex,
|
|
369
374
|
StorageManager,
|
|
370
375
|
compareEntityTimestamps,
|
|
371
376
|
fingerprintEntityStructuredFacts,
|
|
372
377
|
normalizeAttributePairs,
|
|
373
|
-
parseEntityFile
|
|
374
|
-
|
|
375
|
-
} from "./chunk-BLIWOONZ.js";
|
|
378
|
+
parseEntityFile
|
|
379
|
+
} from "./chunk-CUNTLEJP.js";
|
|
376
380
|
import {
|
|
377
381
|
isValidTranscriptDate,
|
|
378
382
|
loadSpeakerRegistry,
|
|
@@ -383,6 +387,9 @@ import {
|
|
|
383
387
|
import {
|
|
384
388
|
assertPathInsideRoot
|
|
385
389
|
} from "./chunk-5GPPACXK.js";
|
|
390
|
+
import {
|
|
391
|
+
stripAttributesSuffix
|
|
392
|
+
} from "./chunk-SVOZFLIQ.js";
|
|
386
393
|
import {
|
|
387
394
|
attachCitation,
|
|
388
395
|
hasCitationForTemplate,
|
|
@@ -391,9 +398,6 @@ import {
|
|
|
391
398
|
import {
|
|
392
399
|
confidenceTier
|
|
393
400
|
} from "./chunk-NGFEWFNK.js";
|
|
394
|
-
import {
|
|
395
|
-
displayErrorDetail
|
|
396
|
-
} from "./chunk-6KYMPV2O.js";
|
|
397
401
|
import {
|
|
398
402
|
buildQmdRecallCacheKey,
|
|
399
403
|
getCachedQmdRecall,
|
|
@@ -421,7 +425,6 @@ import {
|
|
|
421
425
|
parseFlexibleIsoTimestamp
|
|
422
426
|
} from "./chunk-P7FMDTKL.js";
|
|
423
427
|
import {
|
|
424
|
-
ALL_CATEGORY_DIRS,
|
|
425
428
|
RECALL_FALLBACK_DIRS,
|
|
426
429
|
categoryDirName
|
|
427
430
|
} from "./chunk-VS2IYZRU.js";
|
|
@@ -455,19 +458,19 @@ import {
|
|
|
455
458
|
} from "./chunk-PVGDJXVK.js";
|
|
456
459
|
|
|
457
460
|
// src/orchestrator.ts
|
|
458
|
-
import
|
|
461
|
+
import path2 from "path";
|
|
459
462
|
import os from "os";
|
|
460
|
-
import { createHash
|
|
463
|
+
import { createHash, randomBytes } from "crypto";
|
|
461
464
|
import { existsSync, readFileSync } from "fs";
|
|
462
465
|
import {
|
|
463
|
-
lstat
|
|
464
|
-
mkdir as
|
|
465
|
-
readdir
|
|
466
|
-
readFile as
|
|
467
|
-
realpath
|
|
468
|
-
stat
|
|
469
|
-
unlink
|
|
470
|
-
writeFile as
|
|
466
|
+
lstat,
|
|
467
|
+
mkdir as mkdir2,
|
|
468
|
+
readdir,
|
|
469
|
+
readFile as readFile2,
|
|
470
|
+
realpath,
|
|
471
|
+
stat,
|
|
472
|
+
unlink,
|
|
473
|
+
writeFile as writeFile2
|
|
471
474
|
} from "fs/promises";
|
|
472
475
|
|
|
473
476
|
// src/procedural/procedure-recall.ts
|
|
@@ -1404,2089 +1407,166 @@ Install it alongside Remnic:
|
|
|
1404
1407
|
}
|
|
1405
1408
|
return results;
|
|
1406
1409
|
}
|
|
1407
|
-
/**
|
|
1408
|
-
* Memories created from wearable transcripts, filterable by source
|
|
1409
|
-
* and/or day. Includes pending_review candidates — the whole point of
|
|
1410
|
-
* review mode is seeing what's queued.
|
|
1411
|
-
*/
|
|
1412
|
-
async transcriptMemories(options = {}) {
|
|
1413
|
-
if (options.date !== void 0 && !isValidTranscriptDate(options.date)) {
|
|
1414
|
-
throw new WearablesInputError(`invalid date '${options.date}' \u2014 expected YYYY-MM-DD`);
|
|
1415
|
-
}
|
|
1416
|
-
if (options.source !== void 0) assertValidSourceId(options.source);
|
|
1417
|
-
const limit = clampLimit(
|
|
1418
|
-
options.limit,
|
|
1419
|
-
MEMORY_LIST_DEFAULT_LIMIT,
|
|
1420
|
-
MEMORY_LIST_MAX_LIMIT,
|
|
1421
|
-
"limit"
|
|
1422
|
-
);
|
|
1423
|
-
const storage = await this.deps.getStorage();
|
|
1424
|
-
const memories = await storage.readAllMemories();
|
|
1425
|
-
const results = [];
|
|
1426
|
-
for (const memory of memories) {
|
|
1427
|
-
const source = memory.frontmatter.source;
|
|
1428
|
-
if (typeof source !== "string" || !source.startsWith(`${WEARABLE_SOURCE_PREFIX}:`)) {
|
|
1429
|
-
continue;
|
|
1430
|
-
}
|
|
1431
|
-
const attrs = memory.frontmatter.structuredAttributes ?? {};
|
|
1432
|
-
const sourceId = attrs.wearableSource;
|
|
1433
|
-
if (options.source !== void 0) {
|
|
1434
|
-
if (sourceId !== options.source && source !== wearableSourceLabel(options.source) && source !== `${wearableSourceLabel(options.source)}:native`) {
|
|
1435
|
-
continue;
|
|
1436
|
-
}
|
|
1437
|
-
}
|
|
1438
|
-
if (options.date !== void 0 && attrs.wearableDate !== options.date) {
|
|
1439
|
-
continue;
|
|
1440
|
-
}
|
|
1441
|
-
results.push({
|
|
1442
|
-
id: memory.frontmatter.id,
|
|
1443
|
-
source: sourceId ?? source,
|
|
1444
|
-
date: attrs.wearableDate,
|
|
1445
|
-
conversationId: attrs.wearableConversationId,
|
|
1446
|
-
status: memory.frontmatter.status,
|
|
1447
|
-
content: memory.content,
|
|
1448
|
-
created: memory.frontmatter.created
|
|
1449
|
-
});
|
|
1450
|
-
}
|
|
1451
|
-
results.sort((a, b) => {
|
|
1452
|
-
if (a.created > b.created) return -1;
|
|
1453
|
-
if (a.created < b.created) return 1;
|
|
1454
|
-
if (a.id < b.id) return -1;
|
|
1455
|
-
if (a.id > b.id) return 1;
|
|
1456
|
-
return 0;
|
|
1457
|
-
});
|
|
1458
|
-
return results.slice(0, limit);
|
|
1459
|
-
}
|
|
1460
|
-
// -- speakers -------------------------------------------------------------
|
|
1461
|
-
async listSpeakers() {
|
|
1462
|
-
const storage = await this.deps.getStorage();
|
|
1463
|
-
return loadSpeakerRegistry(storage.dir);
|
|
1464
|
-
}
|
|
1465
|
-
async setSpeaker(sourceId, speakerKey, name, opts = {}) {
|
|
1466
|
-
if (typeof name !== "string" || name.trim().length === 0) {
|
|
1467
|
-
throw new WearablesInputError("speaker name must be a non-empty string");
|
|
1468
|
-
}
|
|
1469
|
-
if (typeof speakerKey !== "string" || speakerKey.trim().length === 0) {
|
|
1470
|
-
throw new WearablesInputError("speaker key must be a non-empty string");
|
|
1471
|
-
}
|
|
1472
|
-
const storage = await this.deps.getStorage();
|
|
1473
|
-
const registry = await loadSpeakerRegistry(storage.dir);
|
|
1474
|
-
registry.speakers[speakerRegistryKey(sourceId, speakerKey.trim())] = {
|
|
1475
|
-
name: name.trim(),
|
|
1476
|
-
...opts.isSelf === true ? { isSelf: true } : {},
|
|
1477
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1478
|
-
};
|
|
1479
|
-
await saveSpeakerRegistry(storage.dir, registry);
|
|
1480
|
-
return registry;
|
|
1481
|
-
}
|
|
1482
|
-
async setSelfName(name) {
|
|
1483
|
-
if (typeof name !== "string" || name.trim().length === 0) {
|
|
1484
|
-
throw new WearablesInputError("self name must be a non-empty string");
|
|
1485
|
-
}
|
|
1486
|
-
const storage = await this.deps.getStorage();
|
|
1487
|
-
const registry = await loadSpeakerRegistry(storage.dir);
|
|
1488
|
-
registry.selfName = name.trim();
|
|
1489
|
-
await saveSpeakerRegistry(storage.dir, registry);
|
|
1490
|
-
return registry;
|
|
1491
|
-
}
|
|
1492
|
-
async removeSpeaker(sourceId, speakerKey) {
|
|
1493
|
-
const storage = await this.deps.getStorage();
|
|
1494
|
-
const registry = await loadSpeakerRegistry(storage.dir);
|
|
1495
|
-
const key = speakerRegistryKey(sourceId, speakerKey.trim());
|
|
1496
|
-
if (!(key in registry.speakers)) {
|
|
1497
|
-
throw new WearablesInputError(`no speaker override stored for '${key}'`);
|
|
1498
|
-
}
|
|
1499
|
-
delete registry.speakers[key];
|
|
1500
|
-
await saveSpeakerRegistry(storage.dir, registry);
|
|
1501
|
-
return registry;
|
|
1502
|
-
}
|
|
1503
|
-
// -- corrections ----------------------------------------------------------
|
|
1504
|
-
async listCorrections() {
|
|
1505
|
-
const storage = await this.deps.getStorage();
|
|
1506
|
-
return {
|
|
1507
|
-
fromConfig: this.deps.config.corrections,
|
|
1508
|
-
fromState: await loadCorrectionsFile(storage.dir),
|
|
1509
|
-
stateFilePath: correctionsFilePath(storage.dir)
|
|
1510
|
-
};
|
|
1511
|
-
}
|
|
1512
|
-
async addCorrection(rule) {
|
|
1513
|
-
compileCorrectionRule(rule, "correction");
|
|
1514
|
-
const storage = await this.deps.getStorage();
|
|
1515
|
-
const rules = await loadCorrectionsFile(storage.dir);
|
|
1516
|
-
const duplicate = rules.some(
|
|
1517
|
-
(existing) => existing.match === rule.match && existing.replace === rule.replace && existing.regex === true === (rule.regex === true)
|
|
1518
|
-
);
|
|
1519
|
-
if (duplicate) {
|
|
1520
|
-
throw new WearablesInputError(
|
|
1521
|
-
`an identical correction rule already exists (match: ${JSON.stringify(rule.match)})`
|
|
1522
|
-
);
|
|
1523
|
-
}
|
|
1524
|
-
rules.push(rule);
|
|
1525
|
-
await saveCorrectionsFile(storage.dir, rules);
|
|
1526
|
-
}
|
|
1527
|
-
async removeCorrection(index) {
|
|
1528
|
-
if (!Number.isInteger(index) || index < 0) {
|
|
1529
|
-
throw new WearablesInputError(`invalid correction index '${index}'`);
|
|
1530
|
-
}
|
|
1531
|
-
const storage = await this.deps.getStorage();
|
|
1532
|
-
const rules = await loadCorrectionsFile(storage.dir);
|
|
1533
|
-
if (index >= rules.length) {
|
|
1534
|
-
throw new WearablesInputError(
|
|
1535
|
-
`correction index ${index} is out of range (have ${rules.length} state rule${rules.length === 1 ? "" : "s"})`
|
|
1536
|
-
);
|
|
1537
|
-
}
|
|
1538
|
-
const [removed] = rules.splice(index, 1);
|
|
1539
|
-
await saveCorrectionsFile(storage.dir, rules);
|
|
1540
|
-
return removed;
|
|
1541
|
-
}
|
|
1542
|
-
};
|
|
1543
|
-
function clampLimit(value, fallback, max, label) {
|
|
1544
|
-
if (value === void 0) return fallback;
|
|
1545
|
-
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1 || value > max) {
|
|
1546
|
-
throw new WearablesInputError(
|
|
1547
|
-
`invalid ${label} '${value}' \u2014 expected an integer between 1 and ${max}`
|
|
1548
|
-
);
|
|
1549
|
-
}
|
|
1550
|
-
return value;
|
|
1551
|
-
}
|
|
1552
|
-
function locateTranscriptPath(hitPath) {
|
|
1553
|
-
const normalized = hitPath.replace(/\\/g, "/");
|
|
1554
|
-
const match = normalized.match(
|
|
1555
|
-
/(?:^|\/)wearables\/([a-z][a-z0-9-]{0,63})\/(\d{4}-\d{2}-\d{2})\.md$/
|
|
1556
|
-
);
|
|
1557
|
-
if (!match) return null;
|
|
1558
|
-
if (!isValidTranscriptDate(match[2])) return null;
|
|
1559
|
-
return { source: match[1], date: match[2] };
|
|
1560
|
-
}
|
|
1561
|
-
function extractSnippet(body, index, matchLength) {
|
|
1562
|
-
const start = Math.max(0, index - 80);
|
|
1563
|
-
const end = Math.min(body.length, index + matchLength + 80);
|
|
1564
|
-
const prefix = start > 0 ? "\u2026" : "";
|
|
1565
|
-
const suffix = end < body.length ? "\u2026" : "";
|
|
1566
|
-
return `${prefix}${body.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
|
|
1567
|
-
}
|
|
1568
|
-
|
|
1569
|
-
// src/namespaces/catalog.ts
|
|
1570
|
-
import path2 from "path";
|
|
1571
|
-
import { randomUUID } from "crypto";
|
|
1572
|
-
import {
|
|
1573
|
-
appendFile,
|
|
1574
|
-
lstat,
|
|
1575
|
-
mkdir as mkdir2,
|
|
1576
|
-
open,
|
|
1577
|
-
readdir,
|
|
1578
|
-
readFile as readFile2,
|
|
1579
|
-
realpath,
|
|
1580
|
-
rename,
|
|
1581
|
-
stat,
|
|
1582
|
-
unlink,
|
|
1583
|
-
utimes,
|
|
1584
|
-
writeFile as writeFile2
|
|
1585
|
-
} from "fs/promises";
|
|
1586
|
-
var NAMESPACE_KINDS = [
|
|
1587
|
-
"default",
|
|
1588
|
-
"self",
|
|
1589
|
-
"shared",
|
|
1590
|
-
"project",
|
|
1591
|
-
"branch",
|
|
1592
|
-
"team-project",
|
|
1593
|
-
"explicit",
|
|
1594
|
-
"legacy"
|
|
1595
|
-
];
|
|
1596
|
-
var NAMESPACE_DISCOVERY_SOURCES = [
|
|
1597
|
-
"config",
|
|
1598
|
-
"write",
|
|
1599
|
-
"read",
|
|
1600
|
-
"scan",
|
|
1601
|
-
"migration"
|
|
1602
|
-
];
|
|
1603
|
-
var CATALOG_FILE = "namespaces.jsonl";
|
|
1604
|
-
var STATE_DIR = "state";
|
|
1605
|
-
var REBUILD_LOCK_FILE = "namespaces.rebuild.lock";
|
|
1606
|
-
var REBUILD_LOCK_STALE_MS = 3e4;
|
|
1607
|
-
var REBUILD_LOCK_MAX_WAIT_MS = 5e3;
|
|
1608
|
-
var REBUILD_LOCK_POLL_MS = 50;
|
|
1609
|
-
var REBUILD_LOCK_HEARTBEAT_MS = 1e4;
|
|
1610
|
-
var MEMORY_DATA_CHILDREN = [
|
|
1611
|
-
...ALL_CATEGORY_DIRS,
|
|
1612
|
-
"entities",
|
|
1613
|
-
"artifacts",
|
|
1614
|
-
"identity",
|
|
1615
|
-
"config",
|
|
1616
|
-
"summaries",
|
|
1617
|
-
"profile.md",
|
|
1618
|
-
"state"
|
|
1619
|
-
];
|
|
1620
|
-
function isCatalogEnabled(config) {
|
|
1621
|
-
if (config.namespacesEnabled !== true) return false;
|
|
1622
|
-
return config.namespaceCatalogEnabled !== false;
|
|
1623
|
-
}
|
|
1624
|
-
var FILE_MEMORY_DATA_CHILDREN = /* @__PURE__ */ new Set(["profile.md"]);
|
|
1625
|
-
function isNotFoundError(err) {
|
|
1626
|
-
return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT";
|
|
1627
|
-
}
|
|
1628
|
-
async function inspectMemoryDataMarker(rootDir, child) {
|
|
1629
|
-
const childPath = path2.join(rootDir, child);
|
|
1630
|
-
let entry;
|
|
1631
|
-
try {
|
|
1632
|
-
entry = await lstat(childPath);
|
|
1633
|
-
} catch (err) {
|
|
1634
|
-
return isNotFoundError(err) ? { state: "absent" } : { state: "invalid", detail: `${child}: ${err instanceof Error ? err.message : String(err)}` };
|
|
1635
|
-
}
|
|
1636
|
-
if (entry.isSymbolicLink()) return { state: "invalid", detail: `${child}: symlink` };
|
|
1637
|
-
if (FILE_MEMORY_DATA_CHILDREN.has(child)) {
|
|
1638
|
-
return entry.isFile() ? { state: "valid" } : { state: "invalid", detail: `${child}: expected file` };
|
|
1639
|
-
}
|
|
1640
|
-
if (!entry.isDirectory()) return { state: "invalid", detail: `${child}: expected directory` };
|
|
1641
|
-
try {
|
|
1642
|
-
const rootReal = await realpath(rootDir);
|
|
1643
|
-
const childReal = await realpath(childPath);
|
|
1644
|
-
return isPathInside(rootReal, childReal) ? { state: "valid" } : { state: "invalid", detail: `${child}: escapes namespace root` };
|
|
1645
|
-
} catch (err) {
|
|
1646
|
-
return { state: "invalid", detail: `${child}: ${err instanceof Error ? err.message : String(err)}` };
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
async function inspectMemoryDataRoot(rootDir) {
|
|
1650
|
-
let hasData = false;
|
|
1651
|
-
for (const child of MEMORY_DATA_CHILDREN) {
|
|
1652
|
-
const marker = await inspectMemoryDataMarker(rootDir, child);
|
|
1653
|
-
if (marker.state === "invalid") {
|
|
1654
|
-
return { hasData: false, invalidMarker: marker.detail };
|
|
1655
|
-
}
|
|
1656
|
-
if (marker.state === "valid") {
|
|
1657
|
-
hasData = true;
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
return { hasData };
|
|
1661
|
-
}
|
|
1662
|
-
async function hasMemoryData(rootDir) {
|
|
1663
|
-
return (await inspectMemoryDataRoot(rootDir)).hasData;
|
|
1664
|
-
}
|
|
1665
|
-
function isValidIsoTimestamp(value) {
|
|
1666
|
-
const ms = Date.parse(value);
|
|
1667
|
-
return Number.isFinite(ms);
|
|
1668
|
-
}
|
|
1669
|
-
function isNamespaceKind(value) {
|
|
1670
|
-
return typeof value === "string" && NAMESPACE_KINDS.includes(value);
|
|
1671
|
-
}
|
|
1672
|
-
function isNamespaceDiscoverySource(value) {
|
|
1673
|
-
return typeof value === "string" && NAMESPACE_DISCOVERY_SOURCES.includes(value);
|
|
1674
|
-
}
|
|
1675
|
-
function coerceRecord(value) {
|
|
1676
|
-
if (typeof value !== "object" || value === null) return null;
|
|
1677
|
-
const v = value;
|
|
1678
|
-
if (typeof v.namespace !== "string") return null;
|
|
1679
|
-
const namespace = normalizeNamespaceIdentity(v.namespace);
|
|
1680
|
-
if (namespace.length === 0) return null;
|
|
1681
|
-
if (typeof v.identityToken !== "string" || v.identityToken.length === 0) return null;
|
|
1682
|
-
const expectedIdentityToken = namespaceIdentityToken(namespace);
|
|
1683
|
-
if (v.identityToken !== expectedIdentityToken) return null;
|
|
1684
|
-
if (typeof v.storageDir !== "string" || v.storageDir.length === 0) return null;
|
|
1685
|
-
if (typeof v.createdAt !== "string" || v.createdAt.length === 0) return null;
|
|
1686
|
-
if (!isValidIsoTimestamp(v.createdAt)) return null;
|
|
1687
|
-
const kind = v.kind === void 0 ? "explicit" : isNamespaceKind(v.kind) ? v.kind : null;
|
|
1688
|
-
if (!kind) return null;
|
|
1689
|
-
const discoveredBy = v.discoveredBy === void 0 ? "scan" : isNamespaceDiscoverySource(v.discoveredBy) ? v.discoveredBy : null;
|
|
1690
|
-
if (!discoveredBy) return null;
|
|
1691
|
-
const record = {
|
|
1692
|
-
namespace,
|
|
1693
|
-
identityToken: expectedIdentityToken,
|
|
1694
|
-
kind,
|
|
1695
|
-
createdAt: v.createdAt,
|
|
1696
|
-
storageDir: v.storageDir,
|
|
1697
|
-
discoveredBy
|
|
1698
|
-
};
|
|
1699
|
-
if (typeof v.principal === "string") record.principal = v.principal;
|
|
1700
|
-
if (typeof v.projectId === "string") record.projectId = v.projectId;
|
|
1701
|
-
if (typeof v.branch === "string") record.branch = v.branch;
|
|
1702
|
-
if (typeof v.parentNamespace === "string") record.parentNamespace = v.parentNamespace;
|
|
1703
|
-
if (typeof v.lastReadAt === "string" && isValidIsoTimestamp(v.lastReadAt)) {
|
|
1704
|
-
record.lastReadAt = v.lastReadAt;
|
|
1705
|
-
}
|
|
1706
|
-
if (typeof v.lastWriteAt === "string" && isValidIsoTimestamp(v.lastWriteAt)) {
|
|
1707
|
-
record.lastWriteAt = v.lastWriteAt;
|
|
1708
|
-
}
|
|
1709
|
-
if (v.lastMaintenanceAt && typeof v.lastMaintenanceAt === "object") {
|
|
1710
|
-
const out = {};
|
|
1711
|
-
for (const [k, val] of Object.entries(v.lastMaintenanceAt)) {
|
|
1712
|
-
if (typeof val === "string" && isValidIsoTimestamp(val)) out[k] = val;
|
|
1713
|
-
}
|
|
1714
|
-
if (Object.keys(out).length > 0) record.lastMaintenanceAt = out;
|
|
1715
|
-
}
|
|
1716
|
-
return record;
|
|
1717
|
-
}
|
|
1718
|
-
function laterIso(a, b) {
|
|
1719
|
-
if (!a) return b;
|
|
1720
|
-
if (!b) return a;
|
|
1721
|
-
const am = Date.parse(a);
|
|
1722
|
-
const bm = Date.parse(b);
|
|
1723
|
-
if (!Number.isFinite(am)) return b;
|
|
1724
|
-
if (!Number.isFinite(bm)) return a;
|
|
1725
|
-
return bm > am ? b : a;
|
|
1726
|
-
}
|
|
1727
|
-
function mergeNewerTouchFields(base, fresh) {
|
|
1728
|
-
const merged = { ...base };
|
|
1729
|
-
const lr = laterIso(base.lastReadAt, fresh.lastReadAt);
|
|
1730
|
-
if (lr) merged.lastReadAt = lr;
|
|
1731
|
-
const lw = laterIso(base.lastWriteAt, fresh.lastWriteAt);
|
|
1732
|
-
if (lw) merged.lastWriteAt = lw;
|
|
1733
|
-
if (base.lastMaintenanceAt || fresh.lastMaintenanceAt) {
|
|
1734
|
-
const jobs = { ...base.lastMaintenanceAt ?? {} };
|
|
1735
|
-
for (const [job, ts] of Object.entries(fresh.lastMaintenanceAt ?? {})) {
|
|
1736
|
-
const latest = laterIso(jobs[job], ts);
|
|
1737
|
-
if (latest) jobs[job] = latest;
|
|
1738
|
-
}
|
|
1739
|
-
if (Object.keys(jobs).length > 0) merged.lastMaintenanceAt = jobs;
|
|
1740
|
-
}
|
|
1741
|
-
return merged;
|
|
1742
|
-
}
|
|
1743
|
-
function serializeRecord(record) {
|
|
1744
|
-
const ordered = {};
|
|
1745
|
-
const source = record;
|
|
1746
|
-
for (const key of Object.keys(source).sort()) {
|
|
1747
|
-
const value = source[key];
|
|
1748
|
-
if (value === void 0) continue;
|
|
1749
|
-
if (key === "lastMaintenanceAt" && value && typeof value === "object") {
|
|
1750
|
-
const sortedJobs = {};
|
|
1751
|
-
for (const jobKey of Object.keys(value).sort()) {
|
|
1752
|
-
sortedJobs[jobKey] = value[jobKey];
|
|
1753
|
-
}
|
|
1754
|
-
ordered[key] = sortedJobs;
|
|
1755
|
-
continue;
|
|
1756
|
-
}
|
|
1757
|
-
ordered[key] = value;
|
|
1758
|
-
}
|
|
1759
|
-
return JSON.stringify(ordered);
|
|
1760
|
-
}
|
|
1761
|
-
function inferKind(namespace, config) {
|
|
1762
|
-
if (namespace === normalizeNamespaceIdentity(config.defaultNamespace)) return "default";
|
|
1763
|
-
if (namespace === normalizeNamespaceIdentity(config.sharedNamespace)) return "shared";
|
|
1764
|
-
if (config.namespacePolicies.some((p) => normalizeNamespaceIdentity(p.name) === namespace)) {
|
|
1765
|
-
return "explicit";
|
|
1766
|
-
}
|
|
1767
|
-
if (/-branch-|^project-[^-]+-branch-/.test(namespace) || namespace.includes("-branch-")) {
|
|
1768
|
-
return "branch";
|
|
1769
|
-
}
|
|
1770
|
-
if (/^team-.*-project-/.test(namespace) || /^team-.*project-/.test(namespace)) {
|
|
1771
|
-
return "team-project";
|
|
1772
|
-
}
|
|
1773
|
-
if (/^project-/.test(namespace) || /-project-/.test(namespace)) {
|
|
1774
|
-
return "project";
|
|
1775
|
-
}
|
|
1776
|
-
return "explicit";
|
|
1777
|
-
}
|
|
1778
|
-
var NamespaceCatalog = class {
|
|
1779
|
-
constructor(config) {
|
|
1780
|
-
this.config = config;
|
|
1781
|
-
this.memoryDir = config.memoryDir;
|
|
1782
|
-
this.stateDir = path2.join(this.memoryDir, STATE_DIR);
|
|
1783
|
-
this.catalogPath = path2.join(this.stateDir, CATALOG_FILE);
|
|
1784
|
-
this.rebuildLockPath = path2.join(this.stateDir, REBUILD_LOCK_FILE);
|
|
1785
|
-
this.defaultNamespaceIdentity = normalizeNamespaceIdentity(config.defaultNamespace);
|
|
1786
|
-
}
|
|
1787
|
-
config;
|
|
1788
|
-
memoryDir;
|
|
1789
|
-
stateDir;
|
|
1790
|
-
catalogPath;
|
|
1791
|
-
rebuildLockPath;
|
|
1792
|
-
// Per-INSTANCE lock owner id (round 6, codex P2 — NBsGP). The rebuild lock
|
|
1793
|
-
// file records this id, not just `process.pid`, so two NamespaceCatalog
|
|
1794
|
-
// instances in the SAME process sharing a memoryDir are NOT mistaken for each
|
|
1795
|
-
// other: a touch on instance B must still wait for instance A's rebuild lock
|
|
1796
|
-
// (different owner id, same PID) instead of skipping as "self-held".
|
|
1797
|
-
lockOwnerId = randomUUID();
|
|
1798
|
-
// Serialized write chain that recovers from rejection (CLAUDE.md rule #40)
|
|
1799
|
-
// so a single failed append cannot permanently poison subsequent writes.
|
|
1800
|
-
writeChain = Promise.resolve();
|
|
1801
|
-
// Test-only seam (round 7 — NEZkA): fires inside a touch's HELD-lock critical
|
|
1802
|
-
// section, after the lock is acquired but BEFORE the read→merge→append. A
|
|
1803
|
-
// deterministic concurrency test installs a hook here to widen the (otherwise
|
|
1804
|
-
// microscopic) window and prove that a cross-process rebuild CANNOT run its
|
|
1805
|
-
// load→rename while a touch holds the lock. Never set in production code.
|
|
1806
|
-
onTouchCriticalSectionForTest;
|
|
1807
|
-
// Test-only seam (round 7 — NEZkA): fires inside a mutating rebuild's HELD-lock
|
|
1808
|
-
// critical section, after the final cross-process re-merge `loadCompacted()` and
|
|
1809
|
-
// BEFORE the atomic `rename()`. This is the EXACT window in which a check-then-
|
|
1810
|
-
// append touch (the old bug) would clobber its append. A deterministic test
|
|
1811
|
-
// installs a hook here to attempt a cross-instance touch in this window and
|
|
1812
|
-
// assert the held mutex blocks it. Never set in production code.
|
|
1813
|
-
onRebuildBeforeRenameForTest;
|
|
1814
|
-
// Test-only seam (NFgCT, codex P2): fires AFTER the lockless disk scan but
|
|
1815
|
-
// BEFORE the rebuild acquires the cross-process file lock for its final
|
|
1816
|
-
// load→merge→rename window. A deterministic test installs a hook here to attempt
|
|
1817
|
-
// a cross-instance touch DURING the scan window and assert it is NOT blocked or
|
|
1818
|
-
// dropped — proving the scan no longer holds the mutex. Never set in production.
|
|
1819
|
-
onRebuildAfterScanForTest;
|
|
1820
|
-
// Test-only seam (NG7Bg, codex P2): fires inside `breakStaleRebuildLock` AFTER it
|
|
1821
|
-
// has judged the lock stale and captured its identity, but BEFORE the final
|
|
1822
|
-
// re-validation+unlink. A deterministic test installs a hook here to REPLACE the
|
|
1823
|
-
// lock file (a fresh holder created a new lock in the race window) and assert the
|
|
1824
|
-
// break is skipped — the replacement's active lock is not deleted. Never set in
|
|
1825
|
-
// production.
|
|
1826
|
-
onBeforeBreakStaleUnlinkForTest;
|
|
1827
|
-
// Normalized (trimmed) default namespace identity (NH-FH, cursor Medium).
|
|
1828
|
-
// Catalog records key namespaces by their NORMALIZED identity
|
|
1829
|
-
// (`normalizeNamespaceIdentity`), but several default-namespace exemptions and
|
|
1830
|
-
// memoryDir-ownership checks compared against the RAW `config.defaultNamespace`.
|
|
1831
|
-
// If the configured default name carries surrounding whitespace the record key
|
|
1832
|
-
// is trimmed while the comparison string is not, so the default row is
|
|
1833
|
-
// misclassified, dropped at read time, or given the wrong storage root. Compare
|
|
1834
|
-
// against this normalized form everywhere instead.
|
|
1835
|
-
defaultNamespaceIdentity;
|
|
1836
|
-
/** Whether the catalog is active (namespaces enabled and catalog not opted out). */
|
|
1837
|
-
get enabled() {
|
|
1838
|
-
return isCatalogEnabled(this.config);
|
|
1839
|
-
}
|
|
1840
|
-
// ── Public enumeration API ──────────────────────────────────────────────
|
|
1841
|
-
/**
|
|
1842
|
-
* Sanitize a record at the enumeration boundary (round 5, cursor Medium + codex
|
|
1843
|
-
* P2; round 6 — NDXHe). Reads return whatever is in `namespaces.jsonl` after
|
|
1844
|
-
* schema checks only, so a tampered or pre-fix row could surface unsafe data to
|
|
1845
|
-
* maintenance/QMD until a rewrite occurs. Two distinct defenses:
|
|
1846
|
-
*
|
|
1847
|
-
* 1. UNSAFE NAMESPACE NAME (NGZqr, codex P2): an unsafe non-default namespace
|
|
1848
|
-
* (e.g. `../evil`, a name with separators, or >64 chars) is REJECTED outright
|
|
1849
|
-
* — return `null` so the caller drops it. The disk SCAN and the hot touch
|
|
1850
|
-
* path both reject such names with the SAME default-exempt `isSafeRouteNamespace`
|
|
1851
|
-
* gate, so the read boundary MUST agree, or `listNamespaces()`/`getNamespaceRecord()`
|
|
1852
|
-
* would expose a namespace those paths reject (note `isStorageDirForNamespace`
|
|
1853
|
-
* can still build a tokenized root even for `../evil`, so storageDir sanitation
|
|
1854
|
-
* alone does not catch it). The default namespace is exempt (it may be a
|
|
1855
|
-
* non-route literal), matching every other validation site.
|
|
1856
|
-
*
|
|
1857
|
-
* 2. UNSAFE storageDir: for an otherwise-valid namespace, apply the SAME contract
|
|
1858
|
-
* as the write path — full containment (`isContainedStorageDir`: lexical +
|
|
1859
|
-
* symlink/realpath) AND namespace ownership (`isStorageDirForNamespace`). When
|
|
1860
|
-
* a record fails EITHER check we substitute the trusted resolved-and-safe root
|
|
1861
|
-
* for that namespace (rule 42: read and write stay symmetric).
|
|
1862
|
-
*/
|
|
1863
|
-
async sanitizeRecordForRead(record) {
|
|
1864
|
-
if (record.namespace !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(record.namespace)) {
|
|
1865
|
-
return null;
|
|
1866
|
-
}
|
|
1867
|
-
if (await this.isContainedStorageDir(record.storageDir) && await this.isStorageDirForNamespace(record.namespace, record.storageDir)) {
|
|
1868
|
-
return record;
|
|
1869
|
-
}
|
|
1870
|
-
const safe = await this.resolveSafeStorageDir(record.namespace);
|
|
1871
|
-
return { ...record, storageDir: safe };
|
|
1872
|
-
}
|
|
1873
|
-
storageRootOwnershipRank(record, resolvedStorageDir, configured) {
|
|
1874
|
-
if (resolvedStorageDir === path2.resolve(this.memoryDir)) {
|
|
1875
|
-
return record.namespace === this.defaultNamespaceIdentity ? 0 : 3;
|
|
1876
|
-
}
|
|
1877
|
-
const leaf = path2.basename(resolvedStorageDir);
|
|
1878
|
-
const tokenOwnsRoot = namespaceIdentityToken(record.namespace) === leaf;
|
|
1879
|
-
if (tokenOwnsRoot && configured.has(record.namespace)) {
|
|
1880
|
-
return 0;
|
|
1881
|
-
}
|
|
1882
|
-
if (record.namespace === leaf) return 1;
|
|
1883
|
-
if (tokenOwnsRoot) return 2;
|
|
1884
|
-
return 3;
|
|
1885
|
-
}
|
|
1886
|
-
configuredNamespaceIdentities() {
|
|
1887
|
-
return new Set(
|
|
1888
|
-
[
|
|
1889
|
-
this.config.defaultNamespace,
|
|
1890
|
-
this.config.sharedNamespace,
|
|
1891
|
-
...this.config.namespacePolicies.map((p) => p.name)
|
|
1892
|
-
].map((n) => normalizeNamespaceIdentity(n)).filter((n) => n.length > 0)
|
|
1893
|
-
);
|
|
1894
|
-
}
|
|
1895
|
-
preferStorageRootOwner(current, candidate, resolvedStorageDir, configured) {
|
|
1896
|
-
const currentRank = this.storageRootOwnershipRank(current, resolvedStorageDir, configured);
|
|
1897
|
-
const candidateRank = this.storageRootOwnershipRank(candidate, resolvedStorageDir, configured);
|
|
1898
|
-
if (candidateRank < currentRank) return candidate;
|
|
1899
|
-
if (candidateRank > currentRank) return current;
|
|
1900
|
-
const byName = candidate.namespace.localeCompare(current.namespace);
|
|
1901
|
-
if (byName < 0) return candidate;
|
|
1902
|
-
if (byName > 0) return current;
|
|
1903
|
-
return candidate.identityToken.localeCompare(current.identityToken) < 0 ? candidate : current;
|
|
1904
|
-
}
|
|
1905
|
-
dropDuplicateStorageRootAliases(records) {
|
|
1906
|
-
const byStorageDir = /* @__PURE__ */ new Map();
|
|
1907
|
-
const configured = this.configuredNamespaceIdentities();
|
|
1908
|
-
for (const record of records) {
|
|
1909
|
-
const resolvedStorageDir = path2.resolve(record.storageDir);
|
|
1910
|
-
const current = byStorageDir.get(resolvedStorageDir);
|
|
1911
|
-
if (!current) {
|
|
1912
|
-
byStorageDir.set(resolvedStorageDir, record);
|
|
1913
|
-
continue;
|
|
1914
|
-
}
|
|
1915
|
-
const owner = this.preferStorageRootOwner(current, record, resolvedStorageDir, configured);
|
|
1916
|
-
const alias = owner === current ? record : current;
|
|
1917
|
-
byStorageDir.set(resolvedStorageDir, mergeNewerTouchFields(owner, alias));
|
|
1918
|
-
}
|
|
1919
|
-
return [...byStorageDir.values()];
|
|
1920
|
-
}
|
|
1921
|
-
async loadSanitizedRecords() {
|
|
1922
|
-
const records = await this.loadCompacted();
|
|
1923
|
-
const sanitized = await Promise.all(
|
|
1924
|
-
[...records.values()].map((r) => this.sanitizeRecordForRead(r))
|
|
1925
|
-
);
|
|
1926
|
-
return this.dropDuplicateStorageRootAliases(
|
|
1927
|
-
sanitized.filter((r) => r !== null)
|
|
1928
|
-
);
|
|
1929
|
-
}
|
|
1930
|
-
async listNamespaces(filter) {
|
|
1931
|
-
if (!this.enabled) return [];
|
|
1932
|
-
let out = await this.loadSanitizedRecords();
|
|
1933
|
-
if (filter?.kind) out = out.filter((r) => r.kind === filter.kind);
|
|
1934
|
-
if (filter?.discoveredBy) out = out.filter((r) => r.discoveredBy === filter.discoveredBy);
|
|
1935
|
-
if (filter?.writtenSince) {
|
|
1936
|
-
const sinceMs = filter.writtenSince.getTime();
|
|
1937
|
-
out = out.filter((r) => {
|
|
1938
|
-
if (!r.lastWriteAt) return false;
|
|
1939
|
-
const ms = Date.parse(r.lastWriteAt);
|
|
1940
|
-
return Number.isFinite(ms) && ms >= sinceMs;
|
|
1941
|
-
});
|
|
1942
|
-
}
|
|
1943
|
-
return out.sort((a, b) => {
|
|
1944
|
-
const byName = a.namespace.localeCompare(b.namespace);
|
|
1945
|
-
if (byName !== 0) return byName;
|
|
1946
|
-
return a.identityToken.localeCompare(b.identityToken);
|
|
1947
|
-
});
|
|
1948
|
-
}
|
|
1949
|
-
async getNamespaceRecord(namespace) {
|
|
1950
|
-
if (!this.enabled) return null;
|
|
1951
|
-
const ns = normalizeNamespaceIdentity(namespace);
|
|
1952
|
-
return (await this.loadSanitizedRecords()).find((record) => record.namespace === ns) ?? null;
|
|
1953
|
-
}
|
|
1954
|
-
// ── Touch API (cheap, failure-tolerant) ─────────────────────────────────
|
|
1955
|
-
async markRead(namespace, metadata) {
|
|
1956
|
-
await this.touch(namespace, "read", metadata);
|
|
1957
|
-
}
|
|
1958
|
-
async markWrite(namespace, metadata) {
|
|
1959
|
-
await this.touch(namespace, "write", metadata);
|
|
1960
|
-
}
|
|
1961
|
-
async markMaintenance(namespace, jobName, at) {
|
|
1962
|
-
if (typeof jobName !== "string" || jobName.trim().length === 0) {
|
|
1963
|
-
throw new Error("markMaintenance requires a non-empty jobName");
|
|
1964
|
-
}
|
|
1965
|
-
await this.touch(namespace, "maintenance", { at }, jobName.trim());
|
|
1966
|
-
}
|
|
1967
|
-
/**
|
|
1968
|
-
* Register namespaces known purely from config (default, shared, explicit
|
|
1969
|
-
* policies). Source `config`. Cheap and idempotent.
|
|
1970
|
-
*/
|
|
1971
|
-
async registerConfiguredNamespaces() {
|
|
1972
|
-
if (!this.enabled) return;
|
|
1973
|
-
const names = /* @__PURE__ */ new Set([
|
|
1974
|
-
this.config.defaultNamespace,
|
|
1975
|
-
this.config.sharedNamespace,
|
|
1976
|
-
...this.config.namespacePolicies.map((p) => p.name)
|
|
1977
|
-
]);
|
|
1978
|
-
for (const ns of names) {
|
|
1979
|
-
if (!ns) continue;
|
|
1980
|
-
if (normalizeNamespaceIdentity(ns) !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(ns)) {
|
|
1981
|
-
continue;
|
|
1982
|
-
}
|
|
1983
|
-
try {
|
|
1984
|
-
await this.register(ns, { discoveredBy: "config" });
|
|
1985
|
-
} catch {
|
|
1986
|
-
}
|
|
1987
|
-
}
|
|
1988
|
-
}
|
|
1989
|
-
/**
|
|
1990
|
-
* Register a namespace whose storage was just resolved by the router. Used as
|
|
1991
|
-
* the router's integration hook (`discoveredBy: config`). Storage dir is
|
|
1992
|
-
* provided so we do not re-resolve it. Failure-tolerant. Returns whether the
|
|
1993
|
-
* registration actually APPENDED (round 6, codex P2 — NEFoX), so the router's
|
|
1994
|
-
* resolve-hook dedup only marks a namespace notified when it truly persisted —
|
|
1995
|
-
* a dropped append (disabled catalog or rebuild-lock-timeout drop) returns
|
|
1996
|
-
* `false` and is retried on the next resolve.
|
|
1997
|
-
*/
|
|
1998
|
-
async registerResolved(namespace, storageDir) {
|
|
1999
|
-
if (!this.enabled) return false;
|
|
2000
|
-
return this.register(namespace, { discoveredBy: "config", storageDir });
|
|
2001
|
-
}
|
|
2002
|
-
/**
|
|
2003
|
-
* Generic register/touch without changing read/write timestamps unless the
|
|
2004
|
-
* source implies it. Validates the namespace and resolves a storage dir.
|
|
2005
|
-
* Returns whether the touch actually appended.
|
|
2006
|
-
*/
|
|
2007
|
-
async register(namespace, metadata) {
|
|
2008
|
-
return this.touch(namespace, "register", metadata);
|
|
2009
|
-
}
|
|
2010
|
-
validateNamespace(namespace) {
|
|
2011
|
-
const ns = normalizeNamespaceIdentity(namespace);
|
|
2012
|
-
if (ns.length === 0) throw new Error("empty namespace");
|
|
2013
|
-
if (ns !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(ns)) {
|
|
2014
|
-
throw new Error(`unsafe namespace: ${ns}`);
|
|
2015
|
-
}
|
|
2016
|
-
return ns;
|
|
2017
|
-
}
|
|
2018
|
-
/**
|
|
2019
|
-
* Resolve the on-disk storage dir for a namespace WITHOUT trusting caller
|
|
2020
|
-
* input. The default namespace may use the legacy memoryDir root; everything
|
|
2021
|
-
* else lives under `<memoryDir>/namespaces/<token>`. Containment is enforced
|
|
2022
|
-
* by rejecting separators/parent-refs in the token.
|
|
2023
|
-
*/
|
|
2024
|
-
resolveStorageDir(namespace) {
|
|
2025
|
-
if (normalizeNamespaceIdentity(namespace) === this.defaultNamespaceIdentity) {
|
|
2026
|
-
return this.memoryDir;
|
|
2027
|
-
}
|
|
2028
|
-
const token = namespaceIdentityToken(namespace);
|
|
2029
|
-
return this.namespaceTokenDir(token);
|
|
2030
|
-
}
|
|
2031
|
-
namespaceTokenDir(token) {
|
|
2032
|
-
if (token.length === 0 || token.includes("/") || token.includes("\\") || token.includes("..") || path2.isAbsolute(token)) {
|
|
2033
|
-
throw new Error(`unsafe namespace token: ${token}`);
|
|
2034
|
-
}
|
|
2035
|
-
return path2.join(this.memoryDir, "namespaces", token);
|
|
2036
|
-
}
|
|
2037
|
-
/**
|
|
2038
|
-
* Whether a candidate storage dir is LEXICALLY contained: it is either the
|
|
2039
|
-
* legacy default root (`memoryDir`) or a strict descendant of
|
|
2040
|
-
* `<memoryDir>/namespaces/`. The router legitimately resolves a namespace to
|
|
2041
|
-
* EITHER the tokenized dir or a legacy raw-name dir under `namespaces/`, so we
|
|
2042
|
-
* accept any contained child rather than a single exact token path. This is a
|
|
2043
|
-
* pure string check — symlink escape is checked separately via realpath.
|
|
2044
|
-
*/
|
|
2045
|
-
isLexicallyContained(candidate) {
|
|
2046
|
-
const resolved = path2.resolve(candidate);
|
|
2047
|
-
if (resolved === path2.resolve(this.memoryDir)) return true;
|
|
2048
|
-
const nsBase = path2.resolve(path2.join(this.memoryDir, "namespaces"));
|
|
2049
|
-
const rel = path2.relative(nsBase, resolved);
|
|
2050
|
-
return rel.length > 0 && !rel.startsWith("..") && !path2.isAbsolute(rel);
|
|
2051
|
-
}
|
|
2052
|
-
/**
|
|
2053
|
-
* Whether a candidate storage dir satisfies the catalog containment contract,
|
|
2054
|
-
* including SYMLINK-escape rejection (round 5, codex P2). A lexically-contained
|
|
2055
|
-
* path that is actually a symlink to an outside directory would let maintenance
|
|
2056
|
-
* or QMD follow it outside `memoryDir`. We mirror `rebuildFromDisk`'s posture:
|
|
2057
|
-
* the path must be lexically contained AND, if it exists on disk, neither the
|
|
2058
|
-
* path itself a symlink nor its realpath escaping the memory root. Non-existent
|
|
2059
|
-
* paths pass the realpath stage (nothing to follow yet) but still must be
|
|
2060
|
-
* lexically contained.
|
|
2061
|
-
*/
|
|
2062
|
-
async isContainedStorageDir(candidate) {
|
|
2063
|
-
if (!this.isLexicallyContained(candidate)) return false;
|
|
2064
|
-
if (path2.resolve(candidate) === path2.resolve(this.memoryDir)) return true;
|
|
2065
|
-
let memoryReal;
|
|
2066
|
-
try {
|
|
2067
|
-
memoryReal = await realpath(this.memoryDir);
|
|
2068
|
-
} catch {
|
|
2069
|
-
memoryReal = path2.resolve(this.memoryDir);
|
|
2070
|
-
}
|
|
2071
|
-
if (await this.hasSymlinkedAncestor(candidate)) return false;
|
|
2072
|
-
try {
|
|
2073
|
-
const stat4 = await lstat(candidate);
|
|
2074
|
-
if (stat4.isSymbolicLink()) return false;
|
|
2075
|
-
if (!stat4.isDirectory()) return false;
|
|
2076
|
-
} catch {
|
|
2077
|
-
return this.isNearestExistingAncestorContained(candidate, memoryReal);
|
|
2078
|
-
}
|
|
2079
|
-
try {
|
|
2080
|
-
const real = await realpath(candidate);
|
|
2081
|
-
return isPathInside(memoryReal, real);
|
|
2082
|
-
} catch {
|
|
2083
|
-
return false;
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
/**
|
|
2087
|
-
* Reject a candidate whose path crosses a SYMLINKED ancestor strictly between
|
|
2088
|
-
* memoryDir and the leaf (codex NVuq5). `realpath`-based containment accepts a
|
|
2089
|
-
* symlinked `<memoryDir>/namespaces` that currently resolves back inside
|
|
2090
|
-
* memoryDir, but the disk scanner rejects such a root and a later retarget would
|
|
2091
|
-
* escape the memory tree — so refuse it here too. The leaf itself is
|
|
2092
|
-
* symlink-checked by the caller; this walks only the intermediate ancestors.
|
|
2093
|
-
*/
|
|
2094
|
-
async hasSymlinkedAncestor(candidate) {
|
|
2095
|
-
const stopAt = path2.resolve(this.memoryDir);
|
|
2096
|
-
let dir = path2.dirname(path2.resolve(candidate));
|
|
2097
|
-
const root = path2.parse(dir).root;
|
|
2098
|
-
while (dir !== stopAt && dir !== root && dir !== path2.dirname(dir)) {
|
|
2099
|
-
try {
|
|
2100
|
-
if ((await lstat(dir)).isSymbolicLink()) return true;
|
|
2101
|
-
} catch {
|
|
2102
|
-
}
|
|
2103
|
-
dir = path2.dirname(dir);
|
|
2104
|
-
}
|
|
2105
|
-
return false;
|
|
2106
|
-
}
|
|
2107
|
-
/**
|
|
2108
|
-
* Walk up from a not-yet-existing candidate to the nearest ancestor that exists
|
|
2109
|
-
* on disk and verify its realpath stays inside `memoryReal` (round 6, codex P2
|
|
2110
|
-
* — NDo79). Rejects a non-existent leaf whose existing parent chain escapes
|
|
2111
|
-
* memoryDir via a symlink. Stops at memoryDir's resolved root.
|
|
2112
|
-
*
|
|
2113
|
-
* The nearest existing ancestor must also be a DIRECTORY (NHIdt, codex P2): if
|
|
2114
|
-
* an existing parent such as `<memoryDir>/namespaces` is a regular FILE (or
|
|
2115
|
-
* socket/fifo), `realpath(parent)` still succeeds and resolves inside memoryDir,
|
|
2116
|
-
* so a containment-only check would ACCEPT a leaf that can never be created — you
|
|
2117
|
-
* cannot mkdir a child under a file. We `lstat` the nearest existing ancestor and
|
|
2118
|
-
* reject when it is not a directory, mirroring the leaf non-directory rejection
|
|
2119
|
-
* (NF21i) and the disk scan, so every containment consumer agrees.
|
|
2120
|
-
*/
|
|
2121
|
-
async isNearestExistingAncestorContained(candidate, memoryReal) {
|
|
2122
|
-
let dir = path2.resolve(candidate);
|
|
2123
|
-
const root = path2.parse(dir).root;
|
|
2124
|
-
for (; ; ) {
|
|
2125
|
-
const parent = path2.dirname(dir);
|
|
2126
|
-
if (parent === dir || dir === root) return false;
|
|
2127
|
-
let real;
|
|
2128
|
-
try {
|
|
2129
|
-
real = await realpath(parent);
|
|
2130
|
-
} catch {
|
|
2131
|
-
dir = parent;
|
|
2132
|
-
continue;
|
|
2133
|
-
}
|
|
2134
|
-
if (!(isPathInside(memoryReal, real) || real === memoryReal)) return false;
|
|
2135
|
-
try {
|
|
2136
|
-
const stat4 = await lstat(real);
|
|
2137
|
-
return stat4.isDirectory();
|
|
2138
|
-
} catch {
|
|
2139
|
-
return false;
|
|
2140
|
-
}
|
|
2141
|
-
}
|
|
2142
|
-
}
|
|
2143
|
-
/**
|
|
2144
|
-
* Resolve the storage dir to persist for a touch, validating any caller-
|
|
2145
|
-
* provided `metadata.storageDir` against the catalog containment contract
|
|
2146
|
-
* (round 4 + round 5, codex P2). `markWrite`/`registerResolved` accept an
|
|
2147
|
-
* explicit storageDir, but persisting it verbatim would let a bad hook or
|
|
2148
|
-
* external consumer write an arbitrary path — including one outside `memoryDir`
|
|
2149
|
-
* or a symlink that escapes it — into the catalog, handing maintenance/QMD an
|
|
2150
|
-
* unsafe root. We accept an explicit (or previously-stored) dir ONLY when it
|
|
2151
|
-
* stays contained under memoryDir (lexically AND via realpath); otherwise we
|
|
2152
|
-
* drop it and fall back to the trusted resolved dir.
|
|
2153
|
-
*/
|
|
2154
|
-
async resolveTouchStorageDir(namespace, explicit, existingDir) {
|
|
2155
|
-
if (explicit !== void 0 && await this.isContainedStorageDir(explicit) && await this.isStorageDirForNamespace(namespace, explicit)) {
|
|
2156
|
-
return explicit;
|
|
2157
|
-
}
|
|
2158
|
-
if (existingDir !== void 0 && await this.isContainedStorageDir(existingDir) && await this.isStorageDirForNamespace(namespace, existingDir)) {
|
|
2159
|
-
return existingDir;
|
|
2160
|
-
}
|
|
2161
|
-
return this.resolveSafeStorageDir(namespace);
|
|
2162
|
-
}
|
|
2163
|
-
/**
|
|
2164
|
-
* Whether `candidate` is a legitimate storage root FOR `namespace` (round 6,
|
|
2165
|
-
* codex P2 — NDATT). Accepts the namespace's router-resolved root, its canonical
|
|
2166
|
-
* lexical tokenized dir, and (for the default namespace only) memoryDir. This
|
|
2167
|
-
* prevents a contained-but-CROSS-NAMESPACE path — another namespace's tree, or
|
|
2168
|
-
* memoryDir for a non-default namespace — from being persisted as this
|
|
2169
|
-
* namespace's root. Compared on resolved (absolute) paths.
|
|
2170
|
-
*/
|
|
2171
|
-
async isStorageDirForNamespace(namespace, candidate) {
|
|
2172
|
-
const resolvedCandidate = path2.resolve(candidate);
|
|
2173
|
-
const valid = /* @__PURE__ */ new Set();
|
|
2174
|
-
try {
|
|
2175
|
-
valid.add(path2.resolve(this.namespaceTokenDir(namespaceIdentityToken(namespace))));
|
|
2176
|
-
} catch {
|
|
2177
|
-
}
|
|
2178
|
-
try {
|
|
2179
|
-
valid.add(path2.resolve(this.namespaceTokenDir(namespace)));
|
|
2180
|
-
} catch {
|
|
2181
|
-
}
|
|
2182
|
-
try {
|
|
2183
|
-
valid.add(path2.resolve(await resolveNamespaceStorageRoot(this.config, namespace)));
|
|
2184
|
-
} catch {
|
|
2185
|
-
}
|
|
2186
|
-
if (normalizeNamespaceIdentity(namespace) === this.defaultNamespaceIdentity) {
|
|
2187
|
-
valid.add(path2.resolve(this.memoryDir));
|
|
2188
|
-
try {
|
|
2189
|
-
valid.add(path2.resolve(await resolveDefaultNamespaceRoot(this.config)));
|
|
2190
|
-
} catch {
|
|
2191
|
-
}
|
|
2192
|
-
}
|
|
2193
|
-
return valid.has(resolvedCandidate);
|
|
2194
|
-
}
|
|
2195
|
-
/**
|
|
2196
|
-
* Resolve the canonical storage dir for a namespace as the LIVE ROUTER would,
|
|
2197
|
-
* but NEVER return a path that escapes the memory root.
|
|
2198
|
-
*
|
|
2199
|
-
* Router alignment (round 4, cursor Medium): a read/register touch with no
|
|
2200
|
-
* explicit storageDir previously used the lexical `resolveStorageDir`, which
|
|
2201
|
-
* always picks `<memoryDir>/namespaces/<token>` (or `memoryDir` for the
|
|
2202
|
-
* default). That diverges from `NamespaceStorageRouter`, which can route to a
|
|
2203
|
-
* legacy raw-name dir or a migrated default root — so a recall touch could
|
|
2204
|
-
* record a contained-but-WRONG root that maintenance/rebuild then targets. We
|
|
2205
|
-
* now delegate to the shared `resolveNamespaceStorageRoot` (the very helper the
|
|
2206
|
-
* router uses) so the catalog records the same on-disk root the router serves.
|
|
2207
|
-
*
|
|
2208
|
-
* Containment (round 5, codex P2): the resolved path can still be a symlink
|
|
2209
|
-
* escaping memoryDir, so we run the full (lexical + realpath) containment
|
|
2210
|
-
* contract. When it FAILS we fall back to a NAMESPACE-SPECIFIC safe root, NOT
|
|
2211
|
-
* a blanket `memoryDir`. Recording `memoryDir` for a non-default namespace
|
|
2212
|
-
* would point enumeration/maintenance at the DEFAULT namespace's tree (round 5,
|
|
2213
|
-
* cursor/codex Medium/P2) — a cross-namespace fanout error. The correct safe
|
|
2214
|
-
* root is the namespace's own lexical tokenized dir
|
|
2215
|
-
* (`<memoryDir>/namespaces/<token>`), which is always contained and is that
|
|
2216
|
-
* namespace's canonical location (we record the lexical PATH as metadata; we do
|
|
2217
|
-
* not follow the escaping symlink). Only the default namespace — or a token so
|
|
2218
|
-
* unsafe even the lexical dir cannot be built — falls back to `memoryDir`.
|
|
2219
|
-
*/
|
|
2220
|
-
async resolveSafeStorageDir(namespace) {
|
|
2221
|
-
let resolved;
|
|
2222
|
-
try {
|
|
2223
|
-
resolved = await resolveNamespaceStorageRoot(this.config, namespace);
|
|
2224
|
-
} catch {
|
|
2225
|
-
return this.safeFallbackStorageDir(namespace);
|
|
2226
|
-
}
|
|
2227
|
-
if (await this.isContainedStorageDir(resolved)) return resolved;
|
|
2228
|
-
return this.safeFallbackStorageDir(namespace);
|
|
2229
|
-
}
|
|
2230
|
-
/**
|
|
2231
|
-
* The namespace-specific contained fallback root, used when the router-resolved
|
|
2232
|
-
* root fails containment (round 5, cursor/codex Medium/P2).
|
|
2233
|
-
*
|
|
2234
|
-
* Preference order:
|
|
2235
|
-
* 1. The namespace's OWN lexical tokenized dir (`namespaces/<token>`) — so a
|
|
2236
|
-
* non-default namespace is NOT pointed at the DEFAULT namespace's `memoryDir`
|
|
2237
|
-
* tree (which would misdirect maintenance fanout). Returned only when the
|
|
2238
|
-
* token dir itself stays CONTAINED (it is not a symlink, and its realpath
|
|
2239
|
-
* does not escape memoryDir — e.g. via a symlinked `namespaces/` parent).
|
|
2240
|
-
* 2. `memoryDir` as a LAST resort — for the default namespace, an unsafe token
|
|
2241
|
-
* that cannot build a contained path, OR the irreparable case where the
|
|
2242
|
-
* token dir's realpath escapes the root (so even its lexical path resolves
|
|
2243
|
-
* outside). NF21m note (codex P2): we deliberately do NOT record the lexical
|
|
2244
|
-
* token dir in that irreparable case — its realpath escapes memoryDir, and
|
|
2245
|
-
* the NDo79 contract REQUIRES that an escaping path is never persisted (a
|
|
2246
|
-
* later mkdir/maintenance/QMD op would follow it outside the root). Since no
|
|
2247
|
-
* contained namespace-specific path exists, containment wins: `memoryDir` is
|
|
2248
|
-
* the only safe root left. A namespace whose token dir's realpath escapes is
|
|
2249
|
-
* an irreparable on-disk state; recording the contained default root is
|
|
2250
|
-
* strictly safer than persisting an escaping one. The common case where the
|
|
2251
|
-
* token dir IS contained is handled by branch 1, so a healthy non-default
|
|
2252
|
-
* namespace never reaches `memoryDir`.
|
|
2253
|
-
*/
|
|
2254
|
-
async safeFallbackStorageDir(namespace) {
|
|
2255
|
-
if (normalizeNamespaceIdentity(namespace) === this.defaultNamespaceIdentity) return this.memoryDir;
|
|
2256
|
-
let tokenDir;
|
|
2257
|
-
try {
|
|
2258
|
-
tokenDir = this.namespaceTokenDir(namespaceIdentityToken(namespace));
|
|
2259
|
-
} catch {
|
|
2260
|
-
return this.memoryDir;
|
|
2261
|
-
}
|
|
2262
|
-
if (await this.isContainedStorageDir(tokenDir)) return tokenDir;
|
|
2263
|
-
return this.memoryDir;
|
|
2264
|
-
}
|
|
2265
|
-
/**
|
|
2266
|
-
* Re-check, NOW, whether a namespace's storage root currently EXISTS on disk
|
|
2267
|
-
* with the SAME safety the directory scan uses (NFJV8, codex P2).
|
|
2268
|
-
*
|
|
2269
|
-
* The rebuild's final re-merge runs under the held lock and folds the freshly
|
|
2270
|
-
* re-read log (`latest`) into the scanned `rebuilt` set. A namespace present in
|
|
2271
|
-
* `latest` (a live touch row) but ABSENT from `rebuilt` is normally PURGED as
|
|
2272
|
-
* deleted (the NATqU "disk scan is authoritative" rule). But there is a TOCTOU
|
|
2273
|
-
* window: a dynamic namespace can be CREATED on disk AFTER `rebuildFromDisk()`
|
|
2274
|
-
* already enumerated `namespaces/` but BEFORE this re-merge. The scan snapshot
|
|
2275
|
-
* missed its new root, yet a gateway `markWrite` already appended a row for it.
|
|
2276
|
-
* Blindly purging that row would rewrite the catalog WITHOUT a live namespace
|
|
2277
|
-
* that now has data on disk, so `writtenSince`/maintenance/QMD consumers miss
|
|
2278
|
-
* it until another touch or rebuild.
|
|
2279
|
-
*
|
|
2280
|
-
* So before purging, we re-resolve the namespace's safe storage root (the same
|
|
2281
|
-
* router-aligned, containment-checked path the scan would have catalogued) and
|
|
2282
|
-
* confirm it is a real, contained, non-symlink directory that actually holds
|
|
2283
|
-
* memory data RIGHT NOW. If so the namespace was created-after-scan and is LIVE
|
|
2284
|
-
* — KEEP its row. This is the precise inverse of NATqU and does NOT reintroduce
|
|
2285
|
-
* it: a touch on a REMOVED root re-checks as ABSENT (no data on disk) and is
|
|
2286
|
-
* still purged; only a root that EXISTS on a fresh re-check is kept.
|
|
2287
|
-
*
|
|
2288
|
-
* Mirrors the per-entry scan checks (symlink rejection + realpath containment +
|
|
2289
|
-
* `hasMemoryData`) so a symlinked/escaping root is never resurrected.
|
|
2290
|
-
*/
|
|
2291
|
-
async liveStorageRootExistsForRebuild(namespace, memoryReal) {
|
|
2292
|
-
let root;
|
|
2293
|
-
try {
|
|
2294
|
-
root = await this.resolveSafeStorageDir(namespace);
|
|
2295
|
-
} catch {
|
|
2296
|
-
return false;
|
|
2297
|
-
}
|
|
2298
|
-
if (normalizeNamespaceIdentity(namespace) !== this.defaultNamespaceIdentity && path2.resolve(root) === path2.resolve(this.memoryDir)) {
|
|
2299
|
-
return false;
|
|
2300
|
-
}
|
|
2301
|
-
let stat4;
|
|
2302
|
-
try {
|
|
2303
|
-
stat4 = await lstat(root);
|
|
2304
|
-
} catch {
|
|
2305
|
-
return false;
|
|
2306
|
-
}
|
|
2307
|
-
if (stat4.isSymbolicLink()) return false;
|
|
2308
|
-
if (!stat4.isDirectory()) return false;
|
|
2309
|
-
try {
|
|
2310
|
-
const real = await realpath(root);
|
|
2311
|
-
if (memoryReal && !isPathInside(memoryReal, real)) return false;
|
|
2312
|
-
} catch {
|
|
2313
|
-
return false;
|
|
2314
|
-
}
|
|
2315
|
-
return hasMemoryData(root);
|
|
2316
|
-
}
|
|
2317
|
-
/**
|
|
2318
|
-
* Record a namespace touch. Returns whether the touch actually APPENDED to the
|
|
2319
|
-
* log (round 6, codex P2 — NEFoX): a disabled catalog or a dropped append (the
|
|
2320
|
-
* NAUf7 rebuild-lock-timeout drop) returns `false`, so callers (e.g. the router
|
|
2321
|
-
* resolve-hook dedup) can avoid marking a dropped registration as completed and
|
|
2322
|
-
* suppressing its retry.
|
|
2323
|
-
*/
|
|
2324
|
-
async touch(namespace, kind, metadata, jobName) {
|
|
2325
|
-
if (!this.enabled) return false;
|
|
2326
|
-
const ns = this.validateNamespace(namespace);
|
|
2327
|
-
const nowIso = (metadata?.at ?? /* @__PURE__ */ new Date()).toISOString();
|
|
2328
|
-
return this.queueCritical(
|
|
2329
|
-
async () => this.withHeldCatalogLock(async (acquired) => {
|
|
2330
|
-
if (!acquired) return false;
|
|
2331
|
-
if (this.onTouchCriticalSectionForTest) {
|
|
2332
|
-
await this.onTouchCriticalSectionForTest();
|
|
2333
|
-
}
|
|
2334
|
-
const records = await this.loadCompacted();
|
|
2335
|
-
const existing = records.get(ns);
|
|
2336
|
-
const storageDir = await this.resolveTouchStorageDir(
|
|
2337
|
-
ns,
|
|
2338
|
-
metadata?.storageDir,
|
|
2339
|
-
existing?.storageDir
|
|
2340
|
-
);
|
|
2341
|
-
const record = existing ? { ...existing } : {
|
|
2342
|
-
namespace: ns,
|
|
2343
|
-
identityToken: namespaceIdentityToken(ns),
|
|
2344
|
-
kind: metadata?.kind ?? inferKind(ns, this.config),
|
|
2345
|
-
createdAt: nowIso,
|
|
2346
|
-
storageDir,
|
|
2347
|
-
discoveredBy: metadata?.discoveredBy ?? (kind === "register" ? "config" : kind === "maintenance" ? "scan" : kind)
|
|
2348
|
-
};
|
|
2349
|
-
record.storageDir = storageDir;
|
|
2350
|
-
if (metadata?.kind) record.kind = metadata.kind;
|
|
2351
|
-
if (metadata?.principal !== void 0) record.principal = metadata.principal;
|
|
2352
|
-
if (metadata?.projectId !== void 0) record.projectId = metadata.projectId;
|
|
2353
|
-
if (metadata?.branch !== void 0) record.branch = metadata.branch;
|
|
2354
|
-
if (metadata?.parentNamespace !== void 0)
|
|
2355
|
-
record.parentNamespace = metadata.parentNamespace;
|
|
2356
|
-
if (kind === "write" && existing && record.discoveredBy === "config") {
|
|
2357
|
-
record.discoveredBy = "write";
|
|
2358
|
-
}
|
|
2359
|
-
if (kind === "read") record.lastReadAt = nowIso;
|
|
2360
|
-
if (kind === "write") record.lastWriteAt = nowIso;
|
|
2361
|
-
if (kind === "maintenance" && jobName) {
|
|
2362
|
-
record.lastMaintenanceAt = { ...record.lastMaintenanceAt ?? {}, [jobName]: nowIso };
|
|
2363
|
-
}
|
|
2364
|
-
await this.appendUnchained(record);
|
|
2365
|
-
return true;
|
|
2366
|
-
})
|
|
2367
|
-
);
|
|
2368
|
-
}
|
|
2369
|
-
// ── Rebuild from disk ────────────────────────────────────────────────────
|
|
2370
|
-
async rebuildFromDisk(options) {
|
|
2371
|
-
const dryRun = options?.dryRun === true;
|
|
2372
|
-
if (!this.enabled) {
|
|
2373
|
-
return { dryRun, records: [], skipped: [], applied: false };
|
|
2374
|
-
}
|
|
2375
|
-
if (dryRun) {
|
|
2376
|
-
return this.queueCritical(async () => this.rebuildInsideChain(dryRun, false));
|
|
2377
|
-
}
|
|
2378
|
-
return this.queueCritical(async () => this.rebuildInsideChain(dryRun, true));
|
|
2379
|
-
}
|
|
2380
|
-
/**
|
|
2381
|
-
* Body of `rebuildFromDisk`, run inside a single `queueCritical` turn. MUST
|
|
2382
|
-
* only be invoked from within the serialized chain so the load and the
|
|
2383
|
-
* rewrite are atomic with respect to concurrent touches (in-process).
|
|
2384
|
-
*
|
|
2385
|
-
* `wantMutate` is true for an `--apply` (the caller intends to rewrite). The
|
|
2386
|
-
* cross-process file lock is acquired LATE — only around the final
|
|
2387
|
-
* load→merge→rename window (NFgCT, codex P2) — never across the disk scan, so a
|
|
2388
|
-
* long scan does not force concurrent gateway touches to wait (and drop their
|
|
2389
|
-
* append). Whether the rewrite actually happened is reported via the result's
|
|
2390
|
-
* `applied`: true only when `wantMutate` AND the lock was acquired.
|
|
2391
|
-
*/
|
|
2392
|
-
async rebuildInsideChain(dryRun, wantMutate) {
|
|
2393
|
-
const existing = await this.loadCompacted();
|
|
2394
|
-
const skipped = [];
|
|
2395
|
-
const rebuilt = /* @__PURE__ */ new Map();
|
|
2396
|
-
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
2397
|
-
let memoryReal = null;
|
|
2398
|
-
try {
|
|
2399
|
-
memoryReal = await realpath(this.memoryDir);
|
|
2400
|
-
} catch {
|
|
2401
|
-
memoryReal = this.memoryDir;
|
|
2402
|
-
}
|
|
2403
|
-
const defaultNs = normalizeNamespaceIdentity(this.config.defaultNamespace);
|
|
2404
|
-
const configured = new Set(
|
|
2405
|
-
[
|
|
2406
|
-
this.config.defaultNamespace,
|
|
2407
|
-
this.config.sharedNamespace,
|
|
2408
|
-
...this.config.namespacePolicies.map((p) => p.name)
|
|
2409
|
-
].map((n) => normalizeNamespaceIdentity(n)).filter((n) => n.length > 0)
|
|
2410
|
-
);
|
|
2411
|
-
const resolvedDefaultRoot = await resolveDefaultNamespaceRoot(this.config);
|
|
2412
|
-
const defaultStorageDir = await this.isContainedStorageDir(resolvedDefaultRoot) ? resolvedDefaultRoot : this.memoryDir;
|
|
2413
|
-
const legacyDefaultHasData = defaultStorageDir === this.memoryDir;
|
|
2414
|
-
for (const ns of configured) {
|
|
2415
|
-
if (!ns) continue;
|
|
2416
|
-
if (ns !== defaultNs && !isSafeRouteNamespace(ns)) {
|
|
2417
|
-
let token;
|
|
2418
|
-
try {
|
|
2419
|
-
token = namespaceIdentityToken(ns);
|
|
2420
|
-
} catch {
|
|
2421
|
-
token = ns;
|
|
2422
|
-
}
|
|
2423
|
-
skipped.push({ token, reason: "unsafe", detail: ns });
|
|
2424
|
-
continue;
|
|
2425
|
-
}
|
|
2426
|
-
let storageDir;
|
|
2427
|
-
if (ns === defaultNs) {
|
|
2428
|
-
storageDir = defaultStorageDir;
|
|
2429
|
-
} else {
|
|
2430
|
-
try {
|
|
2431
|
-
storageDir = await resolveNamespaceStorageRoot(this.config, ns);
|
|
2432
|
-
} catch {
|
|
2433
|
-
storageDir = this.namespaceTokenDir(namespaceIdentityToken(ns));
|
|
2434
|
-
}
|
|
2435
|
-
}
|
|
2436
|
-
if (!await this.isContainedStorageDir(storageDir)) {
|
|
2437
|
-
if (ns === defaultNs) {
|
|
2438
|
-
storageDir = this.memoryDir;
|
|
2439
|
-
} else {
|
|
2440
|
-
skipped.push({ token: namespaceIdentityToken(ns), reason: "escape", detail: storageDir });
|
|
2441
|
-
continue;
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
rebuilt.set(
|
|
2445
|
-
ns,
|
|
2446
|
-
this.mergeForRebuild(existing.get(ns), {
|
|
2447
|
-
namespace: ns,
|
|
2448
|
-
identityToken: namespaceIdentityToken(ns),
|
|
2449
|
-
kind: inferKind(ns, this.config),
|
|
2450
|
-
createdAt: existing.get(ns)?.createdAt ?? nowIso,
|
|
2451
|
-
storageDir,
|
|
2452
|
-
discoveredBy: "config"
|
|
2453
|
-
})
|
|
2454
|
-
);
|
|
2455
|
-
}
|
|
2456
|
-
const namespacesDir = path2.join(this.memoryDir, "namespaces");
|
|
2457
|
-
let entries = [];
|
|
2458
|
-
let namespacesDirSafe = true;
|
|
2459
|
-
try {
|
|
2460
|
-
const rootStat = await lstat(namespacesDir);
|
|
2461
|
-
if (rootStat.isSymbolicLink()) {
|
|
2462
|
-
namespacesDirSafe = false;
|
|
2463
|
-
} else {
|
|
2464
|
-
const realNamespacesDir = await realpath(namespacesDir);
|
|
2465
|
-
if (memoryReal && !isPathInside(memoryReal, realNamespacesDir)) {
|
|
2466
|
-
namespacesDirSafe = false;
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2469
|
-
} catch {
|
|
2470
|
-
namespacesDirSafe = true;
|
|
2471
|
-
}
|
|
2472
|
-
if (!namespacesDirSafe) {
|
|
2473
|
-
skipped.push({ token: "namespaces", reason: "symlink", detail: namespacesDir });
|
|
2474
|
-
} else {
|
|
2475
|
-
try {
|
|
2476
|
-
entries = await readdir(namespacesDir, { withFileTypes: true });
|
|
2477
|
-
} catch {
|
|
2478
|
-
entries = [];
|
|
2479
|
-
}
|
|
2480
|
-
}
|
|
2481
|
-
const scannedFromTokenized = /* @__PURE__ */ new Set();
|
|
2482
|
-
for (const entry of entries) {
|
|
2483
|
-
const token = entry.name;
|
|
2484
|
-
const fullPath = path2.join(namespacesDir, token);
|
|
2485
|
-
let stat4;
|
|
2486
|
-
try {
|
|
2487
|
-
stat4 = await lstat(fullPath);
|
|
2488
|
-
} catch (err) {
|
|
2489
|
-
skipped.push({ token, reason: "error", detail: err instanceof Error ? err.message : String(err) });
|
|
2490
|
-
continue;
|
|
2491
|
-
}
|
|
2492
|
-
if (stat4.isSymbolicLink()) {
|
|
2493
|
-
skipped.push({ token, reason: "symlink", detail: fullPath });
|
|
2494
|
-
continue;
|
|
2495
|
-
}
|
|
2496
|
-
if (!stat4.isDirectory()) continue;
|
|
2497
|
-
try {
|
|
2498
|
-
const real = await realpath(fullPath);
|
|
2499
|
-
if (memoryReal && !isPathInside(memoryReal, real)) {
|
|
2500
|
-
skipped.push({ token, reason: "escape", detail: real });
|
|
2501
|
-
continue;
|
|
2502
|
-
}
|
|
2503
|
-
} catch (err) {
|
|
2504
|
-
skipped.push({ token, reason: "error", detail: err instanceof Error ? err.message : String(err) });
|
|
2505
|
-
continue;
|
|
2506
|
-
}
|
|
2507
|
-
const literalRecord = existing.get(token);
|
|
2508
|
-
const literalOwnsRoot = configured.has(token) || literalRecord !== void 0 && path2.resolve(literalRecord.storageDir) === path2.resolve(fullPath);
|
|
2509
|
-
const tokenDecoded = literalOwnsRoot ? null : namespaceIdentityFromToken(token);
|
|
2510
|
-
const rawDecoded = tokenDecoded && tokenDecoded.length > 0 ? tokenDecoded : token;
|
|
2511
|
-
const decoded = normalizeNamespaceIdentity(rawDecoded);
|
|
2512
|
-
if (decoded.length === 0 || rawDecoded !== decoded) {
|
|
2513
|
-
skipped.push({ token, reason: "unsafe", detail: rawDecoded });
|
|
2514
|
-
continue;
|
|
2515
|
-
}
|
|
2516
|
-
if (decoded !== defaultNs && !isSafeRouteNamespace(decoded)) {
|
|
2517
|
-
skipped.push({ token, reason: "unsafe", detail: decoded });
|
|
2518
|
-
continue;
|
|
2519
|
-
}
|
|
2520
|
-
const memoryData = await inspectMemoryDataRoot(fullPath);
|
|
2521
|
-
if (memoryData.invalidMarker) {
|
|
2522
|
-
skipped.push({
|
|
2523
|
-
token,
|
|
2524
|
-
reason: "unsafe",
|
|
2525
|
-
detail: `invalid memory marker: ${memoryData.invalidMarker}`
|
|
2526
|
-
});
|
|
2527
|
-
continue;
|
|
2528
|
-
}
|
|
2529
|
-
if (!memoryData.hasData) continue;
|
|
2530
|
-
if (decoded === defaultNs) {
|
|
2531
|
-
const def = rebuilt.get(defaultNs);
|
|
2532
|
-
if (def) {
|
|
2533
|
-
def.storageDir = defaultStorageDir;
|
|
2534
|
-
def.kind = "default";
|
|
2535
|
-
}
|
|
2536
|
-
continue;
|
|
2537
|
-
}
|
|
2538
|
-
const isTokenizedEntry = token === namespaceIdentityToken(decoded);
|
|
2539
|
-
if (rebuilt.has(decoded) && scannedFromTokenized.has(decoded) && !isTokenizedEntry) {
|
|
2540
|
-
continue;
|
|
2541
|
-
}
|
|
2542
|
-
if (isTokenizedEntry) scannedFromTokenized.add(decoded);
|
|
2543
|
-
const prior = existing.get(decoded);
|
|
2544
|
-
rebuilt.set(
|
|
2545
|
-
decoded,
|
|
2546
|
-
this.mergeForRebuild(prior, {
|
|
2547
|
-
namespace: decoded,
|
|
2548
|
-
identityToken: namespaceIdentityToken(decoded),
|
|
2549
|
-
kind: inferKind(decoded, this.config),
|
|
2550
|
-
createdAt: prior?.createdAt ?? nowIso,
|
|
2551
|
-
storageDir: fullPath,
|
|
2552
|
-
// Configured-and-present namespaces keep config provenance; purely
|
|
2553
|
-
// discovered ones are scan.
|
|
2554
|
-
discoveredBy: configured.has(decoded) ? "config" : prior?.discoveredBy ?? "scan"
|
|
2555
|
-
})
|
|
2556
|
-
);
|
|
2557
|
-
}
|
|
2558
|
-
if (legacyDefaultHasData && defaultStorageDir === this.memoryDir) {
|
|
2559
|
-
const def = rebuilt.get(defaultNs);
|
|
2560
|
-
if (def) def.kind = "default";
|
|
2561
|
-
}
|
|
2562
|
-
if (!wantMutate) {
|
|
2563
|
-
return this.finishRebuild(rebuilt, skipped, dryRun, false, memoryReal, nowIso);
|
|
2564
|
-
}
|
|
2565
|
-
if (this.onRebuildAfterScanForTest) {
|
|
2566
|
-
await this.onRebuildAfterScanForTest();
|
|
2567
|
-
}
|
|
2568
|
-
return this.withHeldCatalogLock(
|
|
2569
|
-
(acquired) => this.finishRebuild(rebuilt, skipped, dryRun, acquired, memoryReal, nowIso)
|
|
2570
|
-
);
|
|
2571
|
-
}
|
|
2572
|
-
/**
|
|
2573
|
-
* Final load→merge→rename window of a rebuild, factored out so the caller can
|
|
2574
|
-
* run it WITHIN the cross-process file lock (NFgCT, codex P2) without holding
|
|
2575
|
-
* that lock across the preceding disk scan. Re-reads the latest on-disk state,
|
|
2576
|
-
* folds concurrent touches, then (when `canMutate`) atomically rewrites the log.
|
|
2577
|
-
*
|
|
2578
|
-
* `canMutate` records that the cross-process lock was actually held. The
|
|
2579
|
-
* re-merge + rewrite run only when it is true — a dry-run, or an unlocked apply
|
|
2580
|
-
* (lock-acquisition timeout), computes records but does NOT rename, so it can
|
|
2581
|
-
* never clobber a concurrent lock holder's window. `applied` mirrors `canMutate`.
|
|
2582
|
-
*/
|
|
2583
|
-
async finishRebuild(rebuilt, skipped, dryRun, canMutate, memoryReal, nowIso) {
|
|
2584
|
-
if (canMutate) {
|
|
2585
|
-
const latest = await this.loadCompacted();
|
|
2586
|
-
for (const [ns, fresh] of latest) {
|
|
2587
|
-
const current = rebuilt.get(ns);
|
|
2588
|
-
if (!current) {
|
|
2589
|
-
if (ns !== this.defaultNamespaceIdentity && !isSafeRouteNamespace(ns)) {
|
|
2590
|
-
continue;
|
|
2591
|
-
}
|
|
2592
|
-
if (await this.liveStorageRootExistsForRebuild(ns, memoryReal)) {
|
|
2593
|
-
const safeDir = await this.resolveSafeStorageDir(ns);
|
|
2594
|
-
const resolvedSafe = path2.resolve(safeDir);
|
|
2595
|
-
let owningNamespace = null;
|
|
2596
|
-
for (const [otherNs, otherRec] of rebuilt) {
|
|
2597
|
-
if (otherNs !== ns && path2.resolve(otherRec.storageDir) === resolvedSafe) {
|
|
2598
|
-
owningNamespace = otherNs;
|
|
2599
|
-
break;
|
|
2600
|
-
}
|
|
2601
|
-
}
|
|
2602
|
-
if (owningNamespace) {
|
|
2603
|
-
const owner = rebuilt.get(owningNamespace);
|
|
2604
|
-
if (owner) rebuilt.set(owningNamespace, mergeNewerTouchFields(owner, fresh));
|
|
2605
|
-
continue;
|
|
2606
|
-
}
|
|
2607
|
-
rebuilt.set(ns, {
|
|
2608
|
-
...fresh,
|
|
2609
|
-
storageDir: safeDir,
|
|
2610
|
-
identityToken: namespaceIdentityToken(ns),
|
|
2611
|
-
kind: fresh.kind ?? inferKind(ns, this.config),
|
|
2612
|
-
createdAt: fresh.createdAt ?? nowIso
|
|
2613
|
-
});
|
|
2614
|
-
continue;
|
|
2615
|
-
}
|
|
2616
|
-
continue;
|
|
2617
|
-
}
|
|
2618
|
-
rebuilt.set(ns, mergeNewerTouchFields(current, fresh));
|
|
2619
|
-
}
|
|
2620
|
-
}
|
|
2621
|
-
const records = [...rebuilt.values()].sort((a, b) => {
|
|
2622
|
-
const byName = a.namespace.localeCompare(b.namespace);
|
|
2623
|
-
if (byName !== 0) return byName;
|
|
2624
|
-
return a.identityToken.localeCompare(b.identityToken);
|
|
2625
|
-
});
|
|
2626
|
-
if (canMutate) {
|
|
2627
|
-
if (this.onRebuildBeforeRenameForTest) {
|
|
2628
|
-
await this.onRebuildBeforeRenameForTest();
|
|
2629
|
-
}
|
|
2630
|
-
await this.rewriteUnchained(records);
|
|
2631
|
-
}
|
|
2632
|
-
return { dryRun, records, skipped, applied: canMutate };
|
|
2633
|
-
}
|
|
2634
|
-
// ── Cross-process catalog write lock (held mutex) ────────────────────────
|
|
2635
|
-
/**
|
|
2636
|
-
* Run `fn` while HOLDING the shared cross-process advisory lock (round 5, codex
|
|
2637
|
-
* P2; generalized round 7 — NEZkA). This is the SINGLE mutex shared by BOTH the
|
|
2638
|
-
* touch read→merge→append window AND the rebuild final load→merge→rename window,
|
|
2639
|
-
* so a touch and a rebuild in different processes are mutually exclusive over
|
|
2640
|
-
* their respective critical sections — closing the check-then-append gap where a
|
|
2641
|
-
* polled-only touch could append into a rebuild's load→rename window.
|
|
2642
|
-
*
|
|
2643
|
-
* Acquisition is atomic via `open(..., "wx")`. A lock older than
|
|
2644
|
-
* `REBUILD_LOCK_STALE_MS` is treated as a crashed holder and broken. After
|
|
2645
|
-
* `REBUILD_LOCK_MAX_WAIT_MS` of contention we proceed best-effort WITHOUT the
|
|
2646
|
-
* lock rather than block forever. The lock is always released in `finally`.
|
|
2647
|
-
*
|
|
2648
|
-
* IN-PROCESS SAFETY: every caller invokes this from inside (or wrapping) the
|
|
2649
|
-
* per-process `queueCritical` chain, which serializes all catalog mutations in
|
|
2650
|
-
* THIS process. So within one process only one logical holder attempts OS-lock
|
|
2651
|
-
* acquisition at a time — the file lock is never self-contended in-process, and
|
|
2652
|
-
* the lock is acquired and released within a single in-process turn. The file
|
|
2653
|
-
* lock adds only the missing CROSS-process exclusion.
|
|
2654
|
-
*
|
|
2655
|
-
* HEARTBEAT (round 5, cursor/codex Medium/P2): while WE hold the lock a timer
|
|
2656
|
-
* refreshes its mtime every `REBUILD_LOCK_HEARTBEAT_MS`, so a legitimately long
|
|
2657
|
-
* holder (> `REBUILD_LOCK_STALE_MS`) is not treated as a crashed holder and
|
|
2658
|
-
* unlinked by another process — which would let overlapping windows lose
|
|
2659
|
-
* appends. Heartbeat failures are swallowed; the timer is always cleared in
|
|
2660
|
-
* `finally`.
|
|
2661
|
-
*
|
|
2662
|
-
* ACQUISITION RESULT (round 6, codex P2 — NBPmY): `fn` receives whether WE
|
|
2663
|
-
* actually hold the lock. When acquisition TIMED OUT (another holder is active),
|
|
2664
|
-
* a MUTATING rebuild must NOT perform its load/rename window unlocked, and a
|
|
2665
|
-
* touch must NOT append unlocked — both would recreate the lost-append race. The
|
|
2666
|
-
* caller uses `acquired` to run compute-only (rebuild) or DROP the append
|
|
2667
|
-
* (touch) when unlocked.
|
|
2668
|
-
*/
|
|
2669
|
-
async withHeldCatalogLock(fn) {
|
|
2670
|
-
const acquired = await this.acquireRebuildLock();
|
|
2671
|
-
let heartbeat;
|
|
2672
|
-
if (acquired) {
|
|
2673
|
-
heartbeat = setInterval(() => {
|
|
2674
|
-
const now = /* @__PURE__ */ new Date();
|
|
2675
|
-
utimes(this.rebuildLockPath, now, now).catch(() => void 0);
|
|
2676
|
-
}, REBUILD_LOCK_HEARTBEAT_MS);
|
|
2677
|
-
heartbeat.unref?.();
|
|
2678
|
-
}
|
|
2679
|
-
try {
|
|
2680
|
-
return await fn(acquired);
|
|
2681
|
-
} finally {
|
|
2682
|
-
if (heartbeat) clearInterval(heartbeat);
|
|
2683
|
-
if (acquired) {
|
|
2684
|
-
try {
|
|
2685
|
-
if (await this.rebuildLockHeldBySelf()) {
|
|
2686
|
-
await unlink(this.rebuildLockPath);
|
|
2687
|
-
}
|
|
2688
|
-
} catch {
|
|
2689
|
-
}
|
|
2690
|
-
}
|
|
2691
|
-
}
|
|
2692
|
-
}
|
|
2693
|
-
/** Try to acquire the rebuild lock; returns true if WE created it. */
|
|
2694
|
-
async acquireRebuildLock() {
|
|
2695
|
-
const deadline = Date.now() + REBUILD_LOCK_MAX_WAIT_MS;
|
|
2696
|
-
await mkdir2(this.stateDir, { recursive: true });
|
|
2697
|
-
for (; ; ) {
|
|
2698
|
-
try {
|
|
2699
|
-
const handle = await open(this.rebuildLockPath, "wx");
|
|
2700
|
-
try {
|
|
2701
|
-
await handle.writeFile(
|
|
2702
|
-
`${process.pid} ${this.lockOwnerId} ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
2703
|
-
`,
|
|
2704
|
-
"utf8"
|
|
2705
|
-
);
|
|
2706
|
-
} catch {
|
|
2707
|
-
} finally {
|
|
2708
|
-
await handle.close();
|
|
2709
|
-
}
|
|
2710
|
-
return true;
|
|
2711
|
-
} catch (err) {
|
|
2712
|
-
if (err?.code !== "EEXIST") {
|
|
2713
|
-
return false;
|
|
2714
|
-
}
|
|
2715
|
-
await this.breakStaleRebuildLock();
|
|
2716
|
-
if (Date.now() >= deadline) return false;
|
|
2717
|
-
await new Promise((r) => setTimeout(r, REBUILD_LOCK_POLL_MS));
|
|
2718
|
-
}
|
|
2719
|
-
}
|
|
2720
|
-
}
|
|
2721
|
-
/**
|
|
2722
|
-
* Remove the lock file if its mtime is older than the stale threshold.
|
|
2723
|
-
*
|
|
2724
|
-
* REPLACEMENT-SAFE (NG7Bg, codex P2): a plain `stat` → `unlink` has a TOCTOU
|
|
2725
|
-
* window — two processes can both observe the SAME stale lock; one removes it and
|
|
2726
|
-
* creates a FRESH lock, and the other's later `unlink` then deletes that fresh
|
|
2727
|
-
* holder's ACTIVE lock based on the stale identity it read earlier, leaving the
|
|
2728
|
-
* fresh holder running its critical section with no visible lock and reopening the
|
|
2729
|
-
* lost-update race the mutex prevents. We therefore capture the lock's IDENTITY
|
|
2730
|
-
* (its full content line: `<pid> <owner-uuid> <iso>`) when we judge it stale, then
|
|
2731
|
-
* RE-READ immediately before unlinking and only remove it when the content is
|
|
2732
|
-
* byte-identical AND still stale. A replacement lock has a different owner id /
|
|
2733
|
-
* timestamp, so its content differs and we leave it untouched. We never unlink a
|
|
2734
|
-
* lock whose mtime is now fresh (a heartbeat refreshed it) or whose identity
|
|
2735
|
-
* changed (a replacement was created). This is best-effort: any mismatch/vanish
|
|
2736
|
-
* simply skips the break and the caller polls again.
|
|
2737
|
-
*/
|
|
2738
|
-
async breakStaleRebuildLock() {
|
|
2739
|
-
let staleIdentity;
|
|
2740
|
-
try {
|
|
2741
|
-
const info = await stat(this.rebuildLockPath);
|
|
2742
|
-
if (Date.now() - info.mtimeMs <= REBUILD_LOCK_STALE_MS) {
|
|
2743
|
-
return;
|
|
2744
|
-
}
|
|
2745
|
-
staleIdentity = await readFile2(this.rebuildLockPath, "utf8");
|
|
2746
|
-
} catch {
|
|
2747
|
-
return;
|
|
2748
|
-
}
|
|
2749
|
-
if (this.onBeforeBreakStaleUnlinkForTest) {
|
|
2750
|
-
await this.onBeforeBreakStaleUnlinkForTest();
|
|
2751
|
-
}
|
|
2752
|
-
try {
|
|
2753
|
-
const current = await readFile2(this.rebuildLockPath, "utf8");
|
|
2754
|
-
if (current !== staleIdentity) return;
|
|
2755
|
-
const recheck = await stat(this.rebuildLockPath);
|
|
2756
|
-
if (Date.now() - recheck.mtimeMs <= REBUILD_LOCK_STALE_MS) return;
|
|
2757
|
-
await unlink(this.rebuildLockPath).catch(() => void 0);
|
|
2758
|
-
} catch {
|
|
2759
|
-
}
|
|
2760
|
-
}
|
|
2761
|
-
/**
|
|
2762
|
-
* Whether the rebuild lock file was written by THIS instance (round 6, codex
|
|
2763
|
-
* P2 — NBsGP). Matches the per-instance owner id, NOT just `process.pid`: two
|
|
2764
|
-
* NamespaceCatalog instances in the same process share a PID, so a PID-only
|
|
2765
|
-
* check would wrongly treat instance A's lock as self-held by instance B and
|
|
2766
|
-
* let B's touch skip the wait and append into A's rebuild window. Falls back to
|
|
2767
|
-
* the legacy PID-only form for lock files written before owner ids existed.
|
|
2768
|
-
*/
|
|
2769
|
-
async rebuildLockHeldBySelf() {
|
|
2770
|
-
try {
|
|
2771
|
-
const body = await readFile2(this.rebuildLockPath, "utf8");
|
|
2772
|
-
const parts = body.trim().split(/\s+/);
|
|
2773
|
-
const pid = Number.parseInt(parts[0] ?? "", 10);
|
|
2774
|
-
const ownerId = parts[1];
|
|
2775
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2776
|
-
if (ownerId && UUID_RE.test(ownerId)) {
|
|
2777
|
-
return ownerId === this.lockOwnerId;
|
|
2778
|
-
}
|
|
2779
|
-
return Number.isFinite(pid) && pid === process.pid;
|
|
2780
|
-
} catch {
|
|
2781
|
-
return false;
|
|
2782
|
-
}
|
|
2783
|
-
}
|
|
2784
|
-
/**
|
|
2785
|
-
* Merge a prior record's preserved metadata (timestamps, principal hints)
|
|
2786
|
-
* onto a freshly-discovered record. Disk-derived fields (storageDir, kind)
|
|
2787
|
-
* take precedence from the new record.
|
|
2788
|
-
*
|
|
2789
|
-
* PROVENANCE (round 3, cursor Low): `discoveredBy` and `createdAt` are
|
|
2790
|
-
* CREATION-ONLY — identical to the touch path's invariant. A rebuild must NOT
|
|
2791
|
-
* reset a namespace first seen via a `write`/`read` touch back to `config`
|
|
2792
|
-
* just because it is also listed in policies. So when a prior record exists we
|
|
2793
|
-
* carry its `discoveredBy` forward; only brand-new records keep the fresh
|
|
2794
|
-
* (config/scan) provenance.
|
|
2795
|
-
*/
|
|
2796
|
-
mergeForRebuild(prior, fresh) {
|
|
2797
|
-
if (!prior) return fresh;
|
|
2798
|
-
const merged = {
|
|
2799
|
-
...fresh,
|
|
2800
|
-
createdAt: prior.createdAt ?? fresh.createdAt,
|
|
2801
|
-
discoveredBy: prior.discoveredBy ?? fresh.discoveredBy
|
|
2802
|
-
};
|
|
2803
|
-
if (prior.lastReadAt) merged.lastReadAt = prior.lastReadAt;
|
|
2804
|
-
if (prior.lastWriteAt) merged.lastWriteAt = prior.lastWriteAt;
|
|
2805
|
-
if (prior.lastMaintenanceAt) merged.lastMaintenanceAt = { ...prior.lastMaintenanceAt };
|
|
2806
|
-
if (prior.principal !== void 0) merged.principal = prior.principal;
|
|
2807
|
-
if (prior.projectId !== void 0) merged.projectId = prior.projectId;
|
|
2808
|
-
if (prior.branch !== void 0) merged.branch = prior.branch;
|
|
2809
|
-
if (prior.parentNamespace !== void 0) merged.parentNamespace = prior.parentNamespace;
|
|
2810
|
-
return merged;
|
|
2811
|
-
}
|
|
2812
|
-
// ── Persistence ──────────────────────────────────────────────────────────
|
|
2813
|
-
/** Load the JSONL log and fold it into current state (last-record-wins). */
|
|
2814
|
-
async loadCompacted() {
|
|
2815
|
-
const records = /* @__PURE__ */ new Map();
|
|
2816
|
-
let raw;
|
|
2817
|
-
try {
|
|
2818
|
-
raw = await readFile2(this.catalogPath, "utf8");
|
|
2819
|
-
} catch {
|
|
2820
|
-
return records;
|
|
2821
|
-
}
|
|
2822
|
-
for (const line of raw.split("\n")) {
|
|
2823
|
-
const trimmed = line.trim();
|
|
2824
|
-
if (trimmed.length === 0) continue;
|
|
2825
|
-
let parsed;
|
|
2826
|
-
try {
|
|
2827
|
-
parsed = JSON.parse(trimmed);
|
|
2828
|
-
} catch {
|
|
2829
|
-
continue;
|
|
2830
|
-
}
|
|
2831
|
-
const record = coerceRecord(parsed);
|
|
2832
|
-
if (!record) continue;
|
|
2833
|
-
const prior = records.get(record.namespace);
|
|
2834
|
-
records.set(record.namespace, prior ? mergeNewerTouchFields(record, prior) : record);
|
|
2835
|
-
}
|
|
2836
|
-
return records;
|
|
2837
|
-
}
|
|
2838
|
-
/**
|
|
2839
|
-
* Serialize an arbitrary read-modify-write critical section through the single
|
|
2840
|
-
* write chain. Every catalog mutation (touch read+merge+append, full rewrite)
|
|
2841
|
-
* runs through this so they are mutually exclusive: a touch always reads the
|
|
2842
|
-
* latest persisted state before appending, and a rebuild rewrite cannot
|
|
2843
|
-
* interleave with a touch's append. The chain recovers from rejection
|
|
2844
|
-
* (CLAUDE.md rule #40) — one failed section never poisons subsequent ones —
|
|
2845
|
-
* while still surfacing the error to that section's awaited promise.
|
|
2846
|
-
*/
|
|
2847
|
-
queueCritical(fn) {
|
|
2848
|
-
const run = this.writeChain.then(fn);
|
|
2849
|
-
this.writeChain = run.then(
|
|
2850
|
-
() => void 0,
|
|
2851
|
-
() => void 0
|
|
2852
|
-
);
|
|
2853
|
-
return run;
|
|
2854
|
-
}
|
|
2855
|
-
/**
|
|
2856
|
-
* Append a single record to the JSONL log WITHOUT re-serializing through the
|
|
2857
|
-
* write chain. MUST only be called from inside a `queueCritical(...)` section
|
|
2858
|
-
* (which already holds the serialized turn); calling it directly would bypass
|
|
2859
|
-
* the read-before-append ordering that prevents lost-field races.
|
|
2860
|
-
*/
|
|
2861
|
-
async appendUnchained(record) {
|
|
2862
|
-
const line = serializeRecord(record) + "\n";
|
|
2863
|
-
await mkdir2(this.stateDir, { recursive: true });
|
|
2864
|
-
await appendFile(this.catalogPath, line, "utf8");
|
|
2865
|
-
}
|
|
2866
|
-
/**
|
|
2867
|
-
* Atomic temp-file + rename rewrite (CLAUDE.md rule #54: write temp, then
|
|
2868
|
-
* rename — never delete-before-write) WITHOUT re-entering the write chain.
|
|
2869
|
-
* MUST only be called from inside a `queueCritical(...)` turn (e.g. the
|
|
2870
|
-
* rebuild critical section, which already holds the serialized turn so its
|
|
2871
|
-
* load and rewrite are atomic against concurrent touches). Re-entering the
|
|
2872
|
-
* chain from within a held turn would deadlock.
|
|
2873
|
-
*/
|
|
2874
|
-
async rewriteUnchained(records) {
|
|
2875
|
-
const body = records.map((r) => serializeRecord(r)).join("\n") + (records.length > 0 ? "\n" : "");
|
|
2876
|
-
await mkdir2(this.stateDir, { recursive: true });
|
|
2877
|
-
const tmp = `${this.catalogPath}.${process.pid}.${Date.now()}.tmp`;
|
|
2878
|
-
await writeFile2(tmp, body, "utf8");
|
|
2879
|
-
await rename(tmp, this.catalogPath);
|
|
2880
|
-
}
|
|
2881
|
-
};
|
|
2882
|
-
function isPathInside(root, child) {
|
|
2883
|
-
const relative = path2.relative(root, child);
|
|
2884
|
-
return relative === "" || !relative.startsWith("..") && !path2.isAbsolute(relative);
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
// src/maintenance/namespace-planner.ts
|
|
2888
|
-
import { createHash, randomUUID as randomUUID2 } from "crypto";
|
|
2889
|
-
import { lstat as lstat2, mkdir as mkdir3, open as open2, readFile as readFile3, readdir as readdir2, rename as rename2, rm, rmdir, utimes as utimes2, writeFile as writeFile3 } from "fs/promises";
|
|
2890
|
-
import path3 from "path";
|
|
2891
|
-
var DEFAULT_MAX_NAMESPACES_PER_CYCLE = 20;
|
|
2892
|
-
var DEFAULT_LOCK_STALE_MS = 10 * 6e4;
|
|
2893
|
-
var LOCK_BASE = "maintenance-locks";
|
|
2894
|
-
var STATUS_BASE = "namespace-maintenance-status";
|
|
2895
|
-
var namespaceMaintenanceFs = { open: open2, rm };
|
|
2896
|
-
function configuredNamespaces(config) {
|
|
2897
|
-
return Array.from(
|
|
2898
|
-
new Set(
|
|
2899
|
-
[config.defaultNamespace, config.sharedNamespace, ...config.namespacePolicies.map((policy) => policy.name)].map((value) => value.trim()).filter(Boolean)
|
|
2900
|
-
)
|
|
2901
|
-
);
|
|
2902
|
-
}
|
|
2903
|
-
function inferConfiguredKind(config, namespace) {
|
|
2904
|
-
if (namespace === config.defaultNamespace.trim()) return "default";
|
|
2905
|
-
if (namespace === config.sharedNamespace.trim()) return "shared";
|
|
2906
|
-
return "explicit";
|
|
2907
|
-
}
|
|
2908
|
-
function maxNamespacesPerCycle(config) {
|
|
2909
|
-
return Math.max(
|
|
2910
|
-
1,
|
|
2911
|
-
Math.floor(
|
|
2912
|
-
typeof config.maintenanceMaxNamespacesPerCycle === "number" && Number.isFinite(config.maintenanceMaxNamespacesPerCycle) ? config.maintenanceMaxNamespacesPerCycle : DEFAULT_MAX_NAMESPACES_PER_CYCLE
|
|
2913
|
-
)
|
|
2914
|
-
);
|
|
2915
|
-
}
|
|
2916
|
-
function namespaceKindAllowed(config, kind) {
|
|
2917
|
-
switch (kind) {
|
|
2918
|
-
case "branch":
|
|
2919
|
-
return config.maintenanceIncludeBranchNamespaces === true;
|
|
2920
|
-
case "project":
|
|
2921
|
-
return config.maintenanceIncludeProjectNamespaces !== false;
|
|
2922
|
-
case "team-project":
|
|
2923
|
-
return config.maintenanceIncludeTeamProjectNamespaces !== false;
|
|
2924
|
-
default:
|
|
2925
|
-
return true;
|
|
2926
|
-
}
|
|
2927
|
-
}
|
|
2928
|
-
function disabledReasonForKind(kind) {
|
|
2929
|
-
if (kind === "branch") return "branch_disabled";
|
|
2930
|
-
if (kind === "project") return "project_disabled";
|
|
2931
|
-
if (kind === "team-project") return "team_project_disabled";
|
|
2932
|
-
return "fanout_disabled";
|
|
2933
|
-
}
|
|
2934
|
-
async function catalogRootIsLive(config, record) {
|
|
2935
|
-
if (typeof record.storageDir !== "string" || record.storageDir.length === 0) {
|
|
2936
|
-
return false;
|
|
2937
|
-
}
|
|
2938
|
-
try {
|
|
2939
|
-
const liveRoot = await resolveNamespaceStorageRoot(config, record.namespace);
|
|
2940
|
-
if (path3.resolve(liveRoot) !== path3.resolve(record.storageDir)) return false;
|
|
2941
|
-
return hasMemoryData(liveRoot);
|
|
2942
|
-
} catch {
|
|
2943
|
-
return false;
|
|
2944
|
-
}
|
|
2945
|
-
}
|
|
2946
|
-
function candidateSortKey(candidate) {
|
|
2947
|
-
const write = candidate.lastWriteAt ?? "";
|
|
2948
|
-
return `${write}\0${candidate.namespace}`;
|
|
2949
|
-
}
|
|
2950
|
-
function candidatePriority(candidate) {
|
|
2951
|
-
if (candidate.kind === "default") return 0;
|
|
2952
|
-
if (candidate.kind === "shared") return 1;
|
|
2953
|
-
if (candidate.source === "configured") return 2;
|
|
2954
|
-
if (candidate.kind === "team-project") return 3;
|
|
2955
|
-
if (candidate.kind === "project") return 4;
|
|
2956
|
-
if (candidate.kind === "self") return 5;
|
|
2957
|
-
if (candidate.kind === "legacy") return 6;
|
|
2958
|
-
if (candidate.kind === "branch") return 8;
|
|
2959
|
-
return 7;
|
|
2960
|
-
}
|
|
2961
|
-
function sortCandidates(a, b) {
|
|
2962
|
-
const priority = candidatePriority(a) - candidatePriority(b);
|
|
2963
|
-
if (priority !== 0) return priority;
|
|
2964
|
-
const am = Date.parse(a.lastMaintenanceAt ?? "");
|
|
2965
|
-
const bm = Date.parse(b.lastMaintenanceAt ?? "");
|
|
2966
|
-
const aMaintained = Number.isFinite(am);
|
|
2967
|
-
const bMaintained = Number.isFinite(bm);
|
|
2968
|
-
if (aMaintained && bMaintained && am !== bm) return am - bm;
|
|
2969
|
-
if (aMaintained !== bMaintained) return aMaintained ? 1 : -1;
|
|
2970
|
-
const aw = Date.parse(a.lastWriteAt ?? "");
|
|
2971
|
-
const bw = Date.parse(b.lastWriteAt ?? "");
|
|
2972
|
-
const aValid = Number.isFinite(aw);
|
|
2973
|
-
const bValid = Number.isFinite(bw);
|
|
2974
|
-
if (aValid && bValid && aw !== bw) return bw - aw;
|
|
2975
|
-
if (aValid !== bValid) return aValid ? -1 : 1;
|
|
2976
|
-
const byKey = candidateSortKey(a).localeCompare(candidateSortKey(b));
|
|
2977
|
-
if (byKey !== 0) return byKey;
|
|
2978
|
-
return a.namespace.localeCompare(b.namespace);
|
|
2979
|
-
}
|
|
2980
|
-
async function planNamespaceMaintenance(config, options) {
|
|
2981
|
-
const generatedAt = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
|
|
2982
|
-
const configured = configuredNamespaces(config);
|
|
2983
|
-
const byNamespace = /* @__PURE__ */ new Map();
|
|
2984
|
-
const skipped = [];
|
|
2985
|
-
for (const namespace of configured) {
|
|
2986
|
-
const kind = inferConfiguredKind(config, namespace);
|
|
2987
|
-
byNamespace.set(namespace, {
|
|
2988
|
-
namespace,
|
|
2989
|
-
kind,
|
|
2990
|
-
source: "configured"
|
|
2991
|
-
});
|
|
2992
|
-
}
|
|
2993
|
-
if (config.namespacesEnabled && config.maintenanceNamespaceFanoutEnabled !== false) {
|
|
2994
|
-
const configuredSet = new Set(configured);
|
|
2995
|
-
try {
|
|
2996
|
-
const records = options.catalog?.enabled ? await options.catalog.listNamespaces() : [];
|
|
2997
|
-
for (const record of records) {
|
|
2998
|
-
const namespace = record.namespace.trim();
|
|
2999
|
-
if (!namespace) continue;
|
|
3000
|
-
const isConfigured = configuredSet.has(namespace);
|
|
3001
|
-
const kind = isConfigured ? inferConfiguredKind(config, namespace) : record.kind;
|
|
3002
|
-
if (!namespaceKindAllowed(config, kind)) {
|
|
3003
|
-
skipped.push({
|
|
3004
|
-
namespace,
|
|
3005
|
-
kind,
|
|
3006
|
-
reason: disabledReasonForKind(kind)
|
|
3007
|
-
});
|
|
3008
|
-
continue;
|
|
3009
|
-
}
|
|
3010
|
-
if (!isConfigured && !await catalogRootIsLive(config, record)) {
|
|
3011
|
-
skipped.push({
|
|
3012
|
-
namespace,
|
|
3013
|
-
kind,
|
|
3014
|
-
reason: "unsafe_or_stale_root"
|
|
3015
|
-
});
|
|
3016
|
-
continue;
|
|
3017
|
-
}
|
|
3018
|
-
byNamespace.set(namespace, {
|
|
3019
|
-
namespace,
|
|
3020
|
-
kind,
|
|
3021
|
-
storageDir: record.storageDir,
|
|
3022
|
-
source: isConfigured ? "configured" : "catalog",
|
|
3023
|
-
lastWriteAt: record.lastWriteAt,
|
|
3024
|
-
lastMaintenanceAt: record.lastMaintenanceAt?.[options.jobName]
|
|
3025
|
-
});
|
|
3026
|
-
}
|
|
3027
|
-
} catch (error) {
|
|
3028
|
-
skipped.push({
|
|
3029
|
-
namespace: "*",
|
|
3030
|
-
reason: "catalog_read_failed",
|
|
3031
|
-
detail: error instanceof Error ? error.message : String(error)
|
|
3032
|
-
});
|
|
3033
|
-
}
|
|
3034
|
-
} else if (config.namespacesEnabled) {
|
|
3035
|
-
skipped.push({
|
|
3036
|
-
namespace: "*",
|
|
3037
|
-
reason: "fanout_disabled"
|
|
3038
|
-
});
|
|
3039
|
-
}
|
|
3040
|
-
if (options.budgetMode !== "unbounded") {
|
|
3041
|
-
const latestStatusAtByNamespace = await readLatestStatusAtByNamespace(config, options.jobName);
|
|
3042
|
-
for (const candidate of byNamespace.values()) {
|
|
3043
|
-
if (!candidate.lastMaintenanceAt) {
|
|
3044
|
-
candidate.lastMaintenanceAt = latestStatusAtByNamespace.get(candidate.namespace);
|
|
3045
|
-
}
|
|
3046
|
-
}
|
|
3047
|
-
}
|
|
3048
|
-
const candidates = [...byNamespace.values()].filter((candidate) => namespaceKindAllowed(config, candidate.kind)).sort(sortCandidates);
|
|
3049
|
-
const max = maxNamespacesPerCycle(config);
|
|
3050
|
-
const applyCycleBudget = options.budgetMode !== "unbounded";
|
|
3051
|
-
const selected = applyCycleBudget ? candidates.slice(0, max) : candidates;
|
|
3052
|
-
if (applyCycleBudget) {
|
|
3053
|
-
for (const candidate of candidates.slice(max)) {
|
|
3054
|
-
skipped.push({
|
|
3055
|
-
namespace: candidate.namespace,
|
|
3056
|
-
kind: candidate.kind,
|
|
3057
|
-
reason: "budget_exhausted"
|
|
3058
|
-
});
|
|
3059
|
-
}
|
|
3060
|
-
}
|
|
3061
|
-
return {
|
|
3062
|
-
jobName: options.jobName,
|
|
3063
|
-
generatedAt,
|
|
3064
|
-
namespaces: selected,
|
|
3065
|
-
skipped,
|
|
3066
|
-
budget: {
|
|
3067
|
-
maxNamespacesPerCycle: max,
|
|
3068
|
-
selected: selected.length
|
|
3069
|
-
}
|
|
3070
|
-
};
|
|
3071
|
-
}
|
|
3072
|
-
function stablePathSegment(value) {
|
|
3073
|
-
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 128) || "unnamed";
|
|
3074
|
-
if (sanitized.length <= 128 && sanitized === value) return sanitized;
|
|
3075
|
-
return `${sanitized.slice(0, 80)}-${createHash("sha256").update(value).digest("hex").slice(0, 16)}`;
|
|
3076
|
-
}
|
|
3077
|
-
function namespacePathSegment(namespace) {
|
|
3078
|
-
const token = namespaceIdentityToken(namespace);
|
|
3079
|
-
if (token.length <= 160) return token;
|
|
3080
|
-
return `ns-${createHash("sha256").update(namespace).digest("hex")}`;
|
|
3081
|
-
}
|
|
3082
|
-
function lockPath(config, jobName, namespace) {
|
|
3083
|
-
return path3.join(
|
|
3084
|
-
config.memoryDir,
|
|
3085
|
-
"state",
|
|
3086
|
-
LOCK_BASE,
|
|
3087
|
-
stablePathSegment(jobName),
|
|
3088
|
-
`${namespacePathSegment(namespace)}.lock`
|
|
3089
|
-
);
|
|
3090
|
-
}
|
|
3091
|
-
function namespaceMaintenanceLockStaleMs(config) {
|
|
3092
|
-
if (typeof config.maintenanceNamespaceLockStaleMs === "number" && Number.isFinite(config.maintenanceNamespaceLockStaleMs) && config.maintenanceNamespaceLockStaleMs > 0) {
|
|
3093
|
-
return Math.floor(config.maintenanceNamespaceLockStaleMs);
|
|
3094
|
-
}
|
|
3095
|
-
return DEFAULT_LOCK_STALE_MS;
|
|
3096
|
-
}
|
|
3097
|
-
function namespaceMaintenanceLockHeartbeatMs(config) {
|
|
3098
|
-
const staleMs = namespaceMaintenanceLockStaleMs(config);
|
|
3099
|
-
return Math.max(1, Math.min(3e4, Math.floor(staleMs / 3) || 1));
|
|
3100
|
-
}
|
|
3101
|
-
function errorCode(error) {
|
|
3102
|
-
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
3103
|
-
}
|
|
3104
|
-
async function withNamespaceMaintenanceLockHeartbeat(config, locks, task) {
|
|
3105
|
-
const activeLocks = Array.isArray(locks) ? locks : [locks];
|
|
3106
|
-
const interval = setInterval(() => {
|
|
3107
|
-
for (const lock of activeLocks) {
|
|
3108
|
-
void lock.touch().catch(() => void 0);
|
|
3109
|
-
}
|
|
3110
|
-
}, namespaceMaintenanceLockHeartbeatMs(config));
|
|
3111
|
-
interval.unref?.();
|
|
3112
|
-
try {
|
|
3113
|
-
return await task();
|
|
3114
|
-
} finally {
|
|
3115
|
-
clearInterval(interval);
|
|
3116
|
-
}
|
|
3117
|
-
}
|
|
3118
|
-
async function removeStaleLockDirectory(filePath) {
|
|
3119
|
-
let entries;
|
|
3120
|
-
try {
|
|
3121
|
-
entries = await readdir2(filePath, { withFileTypes: true });
|
|
3122
|
-
} catch (error) {
|
|
3123
|
-
if (errorCode(error) === "ENOENT") return;
|
|
3124
|
-
throw error;
|
|
3125
|
-
}
|
|
3126
|
-
for (const entry of entries) {
|
|
3127
|
-
if (!entry.isFile()) continue;
|
|
3128
|
-
await namespaceMaintenanceFs.rm(path3.join(filePath, entry.name), { force: true });
|
|
3129
|
-
}
|
|
3130
|
-
try {
|
|
3131
|
-
await rmdir(filePath);
|
|
3132
|
-
} catch (error) {
|
|
3133
|
-
if (errorCode(error) === "ENOENT") return;
|
|
3134
|
-
throw error;
|
|
3135
|
-
}
|
|
3136
|
-
}
|
|
3137
|
-
async function tryAcquireNamespaceMaintenanceLock(config, jobName, namespace) {
|
|
3138
|
-
const filePath = lockPath(config, jobName, namespace);
|
|
3139
|
-
await mkdir3(path3.dirname(filePath), { recursive: true });
|
|
3140
|
-
try {
|
|
3141
|
-
const lockId = randomUUID2();
|
|
3142
|
-
await mkdir3(filePath);
|
|
3143
|
-
const ownerPath = path3.join(filePath, `${lockId}.json`);
|
|
3144
|
-
let handle;
|
|
3145
|
-
try {
|
|
3146
|
-
handle = await namespaceMaintenanceFs.open(ownerPath, "wx");
|
|
3147
|
-
await handle.writeFile(
|
|
3148
|
-
`${JSON.stringify({
|
|
3149
|
-
lockId,
|
|
3150
|
-
pid: process.pid,
|
|
3151
|
-
jobName,
|
|
3152
|
-
namespace,
|
|
3153
|
-
acquiredAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3154
|
-
})}
|
|
3155
|
-
`,
|
|
3156
|
-
"utf8"
|
|
3157
|
-
);
|
|
3158
|
-
await handle.close();
|
|
3159
|
-
} catch (setupError) {
|
|
3160
|
-
await handle?.close().catch(() => void 0);
|
|
3161
|
-
await namespaceMaintenanceFs.rm(ownerPath, { force: true }).catch(() => void 0);
|
|
3162
|
-
await rmdir(filePath).catch(() => void 0);
|
|
3163
|
-
throw setupError;
|
|
3164
|
-
}
|
|
3165
|
-
return {
|
|
3166
|
-
path: filePath,
|
|
3167
|
-
async touch() {
|
|
3168
|
-
try {
|
|
3169
|
-
const parsed = JSON.parse(await readFile3(ownerPath, "utf8"));
|
|
3170
|
-
if (parsed.lockId === lockId) {
|
|
3171
|
-
const now = /* @__PURE__ */ new Date();
|
|
3172
|
-
await utimes2(ownerPath, now, now);
|
|
3173
|
-
await utimes2(filePath, now, now);
|
|
3174
|
-
}
|
|
3175
|
-
} catch {
|
|
3176
|
-
}
|
|
3177
|
-
},
|
|
3178
|
-
async release() {
|
|
3179
|
-
try {
|
|
3180
|
-
const parsed = JSON.parse(await readFile3(ownerPath, "utf8"));
|
|
3181
|
-
if (parsed.lockId === lockId) {
|
|
3182
|
-
await namespaceMaintenanceFs.rm(ownerPath, { force: true });
|
|
3183
|
-
await rmdir(filePath).catch(() => void 0);
|
|
3184
|
-
}
|
|
3185
|
-
} catch {
|
|
3186
|
-
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Memories created from wearable transcripts, filterable by source
|
|
1412
|
+
* and/or day. Includes pending_review candidates — the whole point of
|
|
1413
|
+
* review mode is seeing what's queued.
|
|
1414
|
+
*/
|
|
1415
|
+
async transcriptMemories(options = {}) {
|
|
1416
|
+
if (options.date !== void 0 && !isValidTranscriptDate(options.date)) {
|
|
1417
|
+
throw new WearablesInputError(`invalid date '${options.date}' \u2014 expected YYYY-MM-DD`);
|
|
1418
|
+
}
|
|
1419
|
+
if (options.source !== void 0) assertValidSourceId(options.source);
|
|
1420
|
+
const limit = clampLimit(
|
|
1421
|
+
options.limit,
|
|
1422
|
+
MEMORY_LIST_DEFAULT_LIMIT,
|
|
1423
|
+
MEMORY_LIST_MAX_LIMIT,
|
|
1424
|
+
"limit"
|
|
1425
|
+
);
|
|
1426
|
+
const storage = await this.deps.getStorage();
|
|
1427
|
+
const memories = await storage.readAllMemories();
|
|
1428
|
+
const results = [];
|
|
1429
|
+
for (const memory of memories) {
|
|
1430
|
+
const source = memory.frontmatter.source;
|
|
1431
|
+
if (typeof source !== "string" || !source.startsWith(`${WEARABLE_SOURCE_PREFIX}:`)) {
|
|
1432
|
+
continue;
|
|
3187
1433
|
}
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
const s = await lstat2(filePath);
|
|
3194
|
-
if (s.isSymbolicLink()) {
|
|
3195
|
-
return null;
|
|
3196
|
-
}
|
|
3197
|
-
if ((s.isFile() || s.isDirectory()) && Date.now() - s.mtimeMs > staleMs) {
|
|
3198
|
-
try {
|
|
3199
|
-
if (s.isDirectory()) {
|
|
3200
|
-
await removeStaleLockDirectory(filePath);
|
|
3201
|
-
} else {
|
|
3202
|
-
await namespaceMaintenanceFs.rm(filePath, { force: true });
|
|
3203
|
-
}
|
|
3204
|
-
} catch (removeError) {
|
|
3205
|
-
if (errorCode(removeError) === "ENOENT") {
|
|
3206
|
-
return tryAcquireNamespaceMaintenanceLock(config, jobName, namespace);
|
|
3207
|
-
}
|
|
3208
|
-
if (errorCode(removeError) === "ENOTEMPTY") {
|
|
3209
|
-
return null;
|
|
3210
|
-
}
|
|
3211
|
-
throw removeError;
|
|
3212
|
-
}
|
|
3213
|
-
return tryAcquireNamespaceMaintenanceLock(config, jobName, namespace);
|
|
3214
|
-
}
|
|
3215
|
-
} catch (statError) {
|
|
3216
|
-
if (errorCode(statError) === "ENOENT") {
|
|
3217
|
-
return tryAcquireNamespaceMaintenanceLock(config, jobName, namespace);
|
|
1434
|
+
const attrs = memory.frontmatter.structuredAttributes ?? {};
|
|
1435
|
+
const sourceId = attrs.wearableSource;
|
|
1436
|
+
if (options.source !== void 0) {
|
|
1437
|
+
if (sourceId !== options.source && source !== wearableSourceLabel(options.source) && source !== `${wearableSourceLabel(options.source)}:native`) {
|
|
1438
|
+
continue;
|
|
3218
1439
|
}
|
|
3219
|
-
throw statError;
|
|
3220
1440
|
}
|
|
3221
|
-
|
|
1441
|
+
if (options.date !== void 0 && attrs.wearableDate !== options.date) {
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
results.push({
|
|
1445
|
+
id: memory.frontmatter.id,
|
|
1446
|
+
source: sourceId ?? source,
|
|
1447
|
+
date: attrs.wearableDate,
|
|
1448
|
+
conversationId: attrs.wearableConversationId,
|
|
1449
|
+
status: memory.frontmatter.status,
|
|
1450
|
+
content: memory.content,
|
|
1451
|
+
created: memory.frontmatter.created
|
|
1452
|
+
});
|
|
3222
1453
|
}
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
}
|
|
3232
|
-
function lastRanStatusPath(config, jobName, namespace) {
|
|
3233
|
-
return path3.join(statusBasePath(config), stablePathSegment(jobName), `${namespacePathSegment(namespace)}.last-ran.json`);
|
|
3234
|
-
}
|
|
3235
|
-
function parseStatus(value) {
|
|
3236
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
3237
|
-
const v = value;
|
|
3238
|
-
if (typeof v.namespace === "string" && typeof v.jobName === "string" && (v.state === "ran" || v.state === "skipped" || v.state === "failed") && typeof v.startedAt === "string" && typeof v.completedAt === "string") {
|
|
3239
|
-
return v;
|
|
3240
|
-
}
|
|
3241
|
-
return null;
|
|
3242
|
-
}
|
|
3243
|
-
async function readLatestStatusAtByNamespace(config, jobName) {
|
|
3244
|
-
const latest = /* @__PURE__ */ new Map();
|
|
3245
|
-
const latestMs = /* @__PURE__ */ new Map();
|
|
3246
|
-
for (const status of [...await readStatusFiles(config), ...await readLastRanStatusFiles(config)]) {
|
|
3247
|
-
if (status.state !== "ran") continue;
|
|
3248
|
-
if (status.jobName !== jobName) continue;
|
|
3249
|
-
const completedAtMs = Date.parse(status.completedAt);
|
|
3250
|
-
if (!Number.isFinite(completedAtMs)) continue;
|
|
3251
|
-
const previousMs = latestMs.get(status.namespace);
|
|
3252
|
-
if (previousMs !== void 0 && previousMs >= completedAtMs) continue;
|
|
3253
|
-
latestMs.set(status.namespace, completedAtMs);
|
|
3254
|
-
latest.set(status.namespace, status.completedAt);
|
|
3255
|
-
}
|
|
3256
|
-
return latest;
|
|
3257
|
-
}
|
|
3258
|
-
async function readStatusFile(filePath) {
|
|
3259
|
-
try {
|
|
3260
|
-
const parsed = JSON.parse(await readFile3(filePath, "utf8"));
|
|
3261
|
-
return parseStatus(parsed);
|
|
3262
|
-
} catch {
|
|
3263
|
-
return null;
|
|
3264
|
-
}
|
|
3265
|
-
}
|
|
3266
|
-
async function readStatusFiles(config) {
|
|
3267
|
-
if (typeof config.memoryDir !== "string" || config.memoryDir.length === 0) {
|
|
3268
|
-
return [];
|
|
1454
|
+
results.sort((a, b) => {
|
|
1455
|
+
if (a.created > b.created) return -1;
|
|
1456
|
+
if (a.created < b.created) return 1;
|
|
1457
|
+
if (a.id < b.id) return -1;
|
|
1458
|
+
if (a.id > b.id) return 1;
|
|
1459
|
+
return 0;
|
|
1460
|
+
});
|
|
1461
|
+
return results.slice(0, limit);
|
|
3269
1462
|
}
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
jobDirs = await readdir2(root, { withFileTypes: true });
|
|
3275
|
-
} catch {
|
|
3276
|
-
return statuses;
|
|
1463
|
+
// -- speakers -------------------------------------------------------------
|
|
1464
|
+
async listSpeakers() {
|
|
1465
|
+
const storage = await this.deps.getStorage();
|
|
1466
|
+
return loadSpeakerRegistry(storage.dir);
|
|
3277
1467
|
}
|
|
3278
|
-
|
|
3279
|
-
if (
|
|
3280
|
-
|
|
3281
|
-
try {
|
|
3282
|
-
files = await readdir2(path3.join(root, jobDir.name), { withFileTypes: true });
|
|
3283
|
-
} catch {
|
|
3284
|
-
continue;
|
|
1468
|
+
async setSpeaker(sourceId, speakerKey, name, opts = {}) {
|
|
1469
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
1470
|
+
throw new WearablesInputError("speaker name must be a non-empty string");
|
|
3285
1471
|
}
|
|
3286
|
-
|
|
3287
|
-
|
|
3288
|
-
if (file.name.endsWith(".last-ran.json")) continue;
|
|
3289
|
-
const status = await readStatusFile(path3.join(root, jobDir.name, file.name));
|
|
3290
|
-
if (status) statuses.push(status);
|
|
1472
|
+
if (typeof speakerKey !== "string" || speakerKey.trim().length === 0) {
|
|
1473
|
+
throw new WearablesInputError("speaker key must be a non-empty string");
|
|
3291
1474
|
}
|
|
1475
|
+
const storage = await this.deps.getStorage();
|
|
1476
|
+
const registry = await loadSpeakerRegistry(storage.dir);
|
|
1477
|
+
registry.speakers[speakerRegistryKey(sourceId, speakerKey.trim())] = {
|
|
1478
|
+
name: name.trim(),
|
|
1479
|
+
...opts.isSelf === true ? { isSelf: true } : {},
|
|
1480
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1481
|
+
};
|
|
1482
|
+
await saveSpeakerRegistry(storage.dir, registry);
|
|
1483
|
+
return registry;
|
|
3292
1484
|
}
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
if (typeof config.memoryDir !== "string" || config.memoryDir.length === 0) {
|
|
3297
|
-
return [];
|
|
3298
|
-
}
|
|
3299
|
-
const root = statusBasePath(config);
|
|
3300
|
-
const statuses = [];
|
|
3301
|
-
let jobDirs;
|
|
3302
|
-
try {
|
|
3303
|
-
jobDirs = await readdir2(root, { withFileTypes: true });
|
|
3304
|
-
} catch {
|
|
3305
|
-
return statuses;
|
|
3306
|
-
}
|
|
3307
|
-
for (const jobDir of jobDirs) {
|
|
3308
|
-
if (!jobDir.isDirectory()) continue;
|
|
3309
|
-
let files;
|
|
3310
|
-
try {
|
|
3311
|
-
files = await readdir2(path3.join(root, jobDir.name), { withFileTypes: true });
|
|
3312
|
-
} catch {
|
|
3313
|
-
continue;
|
|
3314
|
-
}
|
|
3315
|
-
for (const file of files) {
|
|
3316
|
-
if (!file.isFile() || !file.name.endsWith(".last-ran.json")) continue;
|
|
3317
|
-
const status = await readStatusFile(path3.join(root, jobDir.name, file.name));
|
|
3318
|
-
if (status) statuses.push(status);
|
|
1485
|
+
async setSelfName(name) {
|
|
1486
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
1487
|
+
throw new WearablesInputError("self name must be a non-empty string");
|
|
3319
1488
|
}
|
|
1489
|
+
const storage = await this.deps.getStorage();
|
|
1490
|
+
const registry = await loadSpeakerRegistry(storage.dir);
|
|
1491
|
+
registry.selfName = name.trim();
|
|
1492
|
+
await saveSpeakerRegistry(storage.dir, registry);
|
|
1493
|
+
return registry;
|
|
3320
1494
|
}
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
3325
|
-
|
|
3326
|
-
|
|
3327
|
-
const payload = {
|
|
3328
|
-
version: 1,
|
|
3329
|
-
...status
|
|
3330
|
-
};
|
|
3331
|
-
await writeFile3(temp, `${JSON.stringify(payload, null, 2)}
|
|
3332
|
-
`, "utf8");
|
|
3333
|
-
await rename2(temp, target);
|
|
3334
|
-
}
|
|
3335
|
-
async function writeStatusFile(config, status) {
|
|
3336
|
-
await writeStatusPayload(statusPath(config, status.jobName, status.namespace), status);
|
|
3337
|
-
if (status.state === "ran") {
|
|
3338
|
-
await writeStatusPayload(lastRanStatusPath(config, status.jobName, status.namespace), status);
|
|
3339
|
-
}
|
|
3340
|
-
}
|
|
3341
|
-
async function recordNamespaceMaintenanceStatusSafely(config, status) {
|
|
3342
|
-
try {
|
|
3343
|
-
await writeStatusFile(config, status);
|
|
3344
|
-
} catch {
|
|
3345
|
-
}
|
|
3346
|
-
}
|
|
3347
|
-
function maintenanceErrorDetail(error) {
|
|
3348
|
-
return displayErrorDetail(error) || "Error";
|
|
3349
|
-
}
|
|
3350
|
-
async function runNamespaceMaintenanceBatchPlan(config, plan, runner, catalog, options = {}) {
|
|
3351
|
-
const statuses = [];
|
|
3352
|
-
for (const skipped of plan.skipped) {
|
|
3353
|
-
if (skipped.namespace === "*") continue;
|
|
3354
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3355
|
-
const status = {
|
|
3356
|
-
namespace: skipped.namespace,
|
|
3357
|
-
jobName: plan.jobName,
|
|
3358
|
-
state: "skipped",
|
|
3359
|
-
reason: skipped.reason,
|
|
3360
|
-
startedAt: now,
|
|
3361
|
-
completedAt: now
|
|
3362
|
-
};
|
|
3363
|
-
statuses.push(status);
|
|
3364
|
-
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
3365
|
-
}
|
|
3366
|
-
const acquired = [];
|
|
3367
|
-
try {
|
|
3368
|
-
for (const candidate of plan.namespaces) {
|
|
3369
|
-
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3370
|
-
const lock = await tryAcquireNamespaceMaintenanceLock(config, plan.jobName, candidate.namespace);
|
|
3371
|
-
if (!lock) {
|
|
3372
|
-
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3373
|
-
const status = {
|
|
3374
|
-
namespace: candidate.namespace,
|
|
3375
|
-
jobName: plan.jobName,
|
|
3376
|
-
state: "skipped",
|
|
3377
|
-
reason: "lock_held",
|
|
3378
|
-
startedAt,
|
|
3379
|
-
completedAt
|
|
3380
|
-
};
|
|
3381
|
-
statuses.push(status);
|
|
3382
|
-
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
3383
|
-
continue;
|
|
3384
|
-
}
|
|
3385
|
-
acquired.push({ candidate, lock, startedAt });
|
|
3386
|
-
}
|
|
3387
|
-
} catch (error) {
|
|
3388
|
-
await Promise.all(acquired.map(({ lock }) => lock.release().catch(() => void 0)));
|
|
3389
|
-
throw error;
|
|
3390
|
-
}
|
|
3391
|
-
if (options.requireAllLocks && acquired.length > 0 && acquired.length < plan.namespaces.length) {
|
|
3392
|
-
for (const { candidate, startedAt } of acquired) {
|
|
3393
|
-
const completedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3394
|
-
const status = {
|
|
3395
|
-
namespace: candidate.namespace,
|
|
3396
|
-
jobName: plan.jobName,
|
|
3397
|
-
state: "skipped",
|
|
3398
|
-
reason: "batch_lock_incomplete",
|
|
3399
|
-
startedAt,
|
|
3400
|
-
completedAt
|
|
3401
|
-
};
|
|
3402
|
-
statuses.push(status);
|
|
3403
|
-
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1495
|
+
async removeSpeaker(sourceId, speakerKey) {
|
|
1496
|
+
const storage = await this.deps.getStorage();
|
|
1497
|
+
const registry = await loadSpeakerRegistry(storage.dir);
|
|
1498
|
+
const key = speakerRegistryKey(sourceId, speakerKey.trim());
|
|
1499
|
+
if (!(key in registry.speakers)) {
|
|
1500
|
+
throw new WearablesInputError(`no speaker override stored for '${key}'`);
|
|
3404
1501
|
}
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3409
|
-
ran: statuses.filter((s) => s.state === "ran").length,
|
|
3410
|
-
skipped: statuses.filter((s) => s.state === "skipped").length,
|
|
3411
|
-
failed: statuses.filter((s) => s.state === "failed").length,
|
|
3412
|
-
statuses
|
|
3413
|
-
};
|
|
1502
|
+
delete registry.speakers[key];
|
|
1503
|
+
await saveSpeakerRegistry(storage.dir, registry);
|
|
1504
|
+
return registry;
|
|
3414
1505
|
}
|
|
3415
|
-
|
|
1506
|
+
// -- corrections ----------------------------------------------------------
|
|
1507
|
+
async listCorrections() {
|
|
1508
|
+
const storage = await this.deps.getStorage();
|
|
3416
1509
|
return {
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
skipped: statuses.filter((s) => s.state === "skipped").length,
|
|
3421
|
-
failed: statuses.filter((s) => s.state === "failed").length,
|
|
3422
|
-
statuses
|
|
1510
|
+
fromConfig: this.deps.config.corrections,
|
|
1511
|
+
fromState: await loadCorrectionsFile(storage.dir),
|
|
1512
|
+
stateFilePath: correctionsFilePath(storage.dir)
|
|
3423
1513
|
};
|
|
3424
1514
|
}
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
1515
|
+
async addCorrection(rule) {
|
|
1516
|
+
compileCorrectionRule(rule, "correction");
|
|
1517
|
+
const storage = await this.deps.getStorage();
|
|
1518
|
+
const rules = await loadCorrectionsFile(storage.dir);
|
|
1519
|
+
const duplicate = rules.some(
|
|
1520
|
+
(existing) => existing.match === rule.match && existing.replace === rule.replace && existing.regex === true === (rule.regex === true)
|
|
3430
1521
|
);
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
jobName: plan.jobName,
|
|
3436
|
-
state: "ran",
|
|
3437
|
-
startedAt,
|
|
3438
|
-
completedAt,
|
|
3439
|
-
itemCount: itemCountForNamespace(result, candidate.namespace)
|
|
3440
|
-
};
|
|
3441
|
-
statuses.push(status);
|
|
3442
|
-
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
3443
|
-
try {
|
|
3444
|
-
await catalog?.markMaintenance(candidate.namespace, plan.jobName, new Date(completedAt));
|
|
3445
|
-
} catch {
|
|
3446
|
-
}
|
|
1522
|
+
if (duplicate) {
|
|
1523
|
+
throw new WearablesInputError(
|
|
1524
|
+
`an identical correction rule already exists (match: ${JSON.stringify(rule.match)})`
|
|
1525
|
+
);
|
|
3447
1526
|
}
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
jobName: plan.jobName,
|
|
3455
|
-
state: "skipped",
|
|
3456
|
-
reason: skipReason,
|
|
3457
|
-
startedAt,
|
|
3458
|
-
completedAt
|
|
3459
|
-
} : {
|
|
3460
|
-
namespace: candidate.namespace,
|
|
3461
|
-
jobName: plan.jobName,
|
|
3462
|
-
state: "failed",
|
|
3463
|
-
reason: "job_failed",
|
|
3464
|
-
startedAt,
|
|
3465
|
-
completedAt,
|
|
3466
|
-
error: maintenanceErrorDetail(error)
|
|
3467
|
-
};
|
|
3468
|
-
statuses.push(status);
|
|
3469
|
-
await recordNamespaceMaintenanceStatusSafely(config, status);
|
|
1527
|
+
rules.push(rule);
|
|
1528
|
+
await saveCorrectionsFile(storage.dir, rules);
|
|
1529
|
+
}
|
|
1530
|
+
async removeCorrection(index) {
|
|
1531
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
1532
|
+
throw new WearablesInputError(`invalid correction index '${index}'`);
|
|
3470
1533
|
}
|
|
3471
|
-
|
|
3472
|
-
|
|
1534
|
+
const storage = await this.deps.getStorage();
|
|
1535
|
+
const rules = await loadCorrectionsFile(storage.dir);
|
|
1536
|
+
if (index >= rules.length) {
|
|
1537
|
+
throw new WearablesInputError(
|
|
1538
|
+
`correction index ${index} is out of range (have ${rules.length} state rule${rules.length === 1 ? "" : "s"})`
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
const [removed] = rules.splice(index, 1);
|
|
1542
|
+
await saveCorrectionsFile(storage.dir, rules);
|
|
1543
|
+
return removed;
|
|
3473
1544
|
}
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
};
|
|
3482
|
-
}
|
|
3483
|
-
function itemCountForNamespace(result, namespace) {
|
|
3484
|
-
const itemCounts = result?.itemCounts;
|
|
3485
|
-
if (itemCounts instanceof Map) return itemCounts.get(namespace);
|
|
3486
|
-
if (itemCounts && Object.prototype.hasOwnProperty.call(itemCounts, namespace)) {
|
|
3487
|
-
return itemCounts[namespace];
|
|
1545
|
+
};
|
|
1546
|
+
function clampLimit(value, fallback, max, label) {
|
|
1547
|
+
if (value === void 0) return fallback;
|
|
1548
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 1 || value > max) {
|
|
1549
|
+
throw new WearablesInputError(
|
|
1550
|
+
`invalid ${label} '${value}' \u2014 expected an integer between 1 and ${max}`
|
|
1551
|
+
);
|
|
3488
1552
|
}
|
|
3489
|
-
return
|
|
1553
|
+
return value;
|
|
1554
|
+
}
|
|
1555
|
+
function locateTranscriptPath(hitPath) {
|
|
1556
|
+
const normalized = hitPath.replace(/\\/g, "/");
|
|
1557
|
+
const match = normalized.match(
|
|
1558
|
+
/(?:^|\/)wearables\/([a-z][a-z0-9-]{0,63})\/(\d{4}-\d{2}-\d{2})\.md$/
|
|
1559
|
+
);
|
|
1560
|
+
if (!match) return null;
|
|
1561
|
+
if (!isValidTranscriptDate(match[2])) return null;
|
|
1562
|
+
return { source: match[1], date: match[2] };
|
|
1563
|
+
}
|
|
1564
|
+
function extractSnippet(body, index, matchLength) {
|
|
1565
|
+
const start = Math.max(0, index - 80);
|
|
1566
|
+
const end = Math.min(body.length, index + matchLength + 80);
|
|
1567
|
+
const prefix = start > 0 ? "\u2026" : "";
|
|
1568
|
+
const suffix = end < body.length ? "\u2026" : "";
|
|
1569
|
+
return `${prefix}${body.slice(start, end).replace(/\s+/g, " ").trim()}${suffix}`;
|
|
3490
1570
|
}
|
|
3491
1571
|
|
|
3492
1572
|
// src/scopes/scope-plan.ts
|
|
@@ -3625,7 +1705,7 @@ function flattenStructuredSectionEvidence(sections) {
|
|
|
3625
1705
|
);
|
|
3626
1706
|
}
|
|
3627
1707
|
function fingerprintEntitySynthesisEvidence(entity) {
|
|
3628
|
-
const fingerprint =
|
|
1708
|
+
const fingerprint = createHash("sha256");
|
|
3629
1709
|
const timelineEntries = entity.timeline.map((entry) => [
|
|
3630
1710
|
entry.timestamp,
|
|
3631
1711
|
entry.source ?? "",
|
|
@@ -3675,7 +1755,7 @@ async function raceRecallAbort(promise, signal, message = "recall aborted") {
|
|
|
3675
1755
|
}
|
|
3676
1756
|
}
|
|
3677
1757
|
function qmdCollectionPathParts(resultPath) {
|
|
3678
|
-
if (!resultPath ||
|
|
1758
|
+
if (!resultPath || path2.isAbsolute(resultPath)) return null;
|
|
3679
1759
|
const normalized = resultPath.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
3680
1760
|
const slashIndex = normalized.indexOf("/");
|
|
3681
1761
|
if (slashIndex <= 0 || slashIndex >= normalized.length - 1) return null;
|
|
@@ -3688,9 +1768,9 @@ function qmdCollectionPathParts(resultPath) {
|
|
|
3688
1768
|
}
|
|
3689
1769
|
function qmdResultPathCandidates(storageDir, resultPath) {
|
|
3690
1770
|
const candidates = /* @__PURE__ */ new Set();
|
|
3691
|
-
const storageRoot =
|
|
1771
|
+
const storageRoot = path2.resolve(storageDir);
|
|
3692
1772
|
const addCandidate = (candidate) => {
|
|
3693
|
-
const resolved =
|
|
1773
|
+
const resolved = path2.resolve(candidate);
|
|
3694
1774
|
if (isPathInsideStorageRoot(storageRoot, resolved)) {
|
|
3695
1775
|
candidates.add(resolved);
|
|
3696
1776
|
}
|
|
@@ -3698,12 +1778,12 @@ function qmdResultPathCandidates(storageDir, resultPath) {
|
|
|
3698
1778
|
const addRelativeCandidates = (relativePath) => {
|
|
3699
1779
|
const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
3700
1780
|
if (!normalized) return;
|
|
3701
|
-
addCandidate(
|
|
1781
|
+
addCandidate(path2.join(storageRoot, normalized));
|
|
3702
1782
|
if (/^\d{4}-\d{2}-\d{2}\//.test(normalized)) {
|
|
3703
|
-
addCandidate(
|
|
1783
|
+
addCandidate(path2.join(storageRoot, "facts", normalized));
|
|
3704
1784
|
}
|
|
3705
1785
|
};
|
|
3706
|
-
if (
|
|
1786
|
+
if (path2.isAbsolute(resultPath)) {
|
|
3707
1787
|
addCandidate(resultPath);
|
|
3708
1788
|
} else {
|
|
3709
1789
|
addRelativeCandidates(resultPath);
|
|
@@ -3820,11 +1900,11 @@ async function qmdStartupCollectionCheckWithTimeout(promise, controller, label)
|
|
|
3820
1900
|
return await Promise.race([checkedPromise, timeoutPromise]);
|
|
3821
1901
|
}
|
|
3822
1902
|
function defaultWorkspaceDir() {
|
|
3823
|
-
return
|
|
1903
|
+
return path2.join(os.homedir(), ".openclaw", "workspace");
|
|
3824
1904
|
}
|
|
3825
1905
|
function sanitizeSessionKeyForFilename(sessionKey) {
|
|
3826
1906
|
const readable = sessionKey.replace(/[^a-zA-Z0-9._-]/g, "_");
|
|
3827
|
-
const hash =
|
|
1907
|
+
const hash = createHash("sha256").update(sessionKey).digest("hex").slice(0, 12);
|
|
3828
1908
|
return `${readable}-${hash}`;
|
|
3829
1909
|
}
|
|
3830
1910
|
function latestSourceValidAtFromTurns(turns) {
|
|
@@ -4127,11 +2207,11 @@ function mergeGraphExpandedResults(primary, expanded) {
|
|
|
4127
2207
|
return Array.from(mergedByPath.values());
|
|
4128
2208
|
}
|
|
4129
2209
|
function graphPathRelativeToStorage(storageDir, candidatePath) {
|
|
4130
|
-
const absolutePath =
|
|
4131
|
-
const rel =
|
|
2210
|
+
const absolutePath = path2.isAbsolute(candidatePath) ? candidatePath : path2.resolve(storageDir, candidatePath);
|
|
2211
|
+
const rel = path2.relative(storageDir, absolutePath);
|
|
4132
2212
|
if (!rel || rel === ".") return null;
|
|
4133
2213
|
if (rel.startsWith("..")) return null;
|
|
4134
|
-
return rel.split(
|
|
2214
|
+
return rel.split(path2.sep).join("/");
|
|
4135
2215
|
}
|
|
4136
2216
|
function normalizeGraphActivationScore(score) {
|
|
4137
2217
|
const bounded = Number.isFinite(score) && score > 0 ? score : 0;
|
|
@@ -4273,7 +2353,7 @@ function buildMemoryPathById(allMemsForGraph, storageDir) {
|
|
|
4273
2353
|
for (const mem of allMemsForGraph ?? []) {
|
|
4274
2354
|
const id = mem.frontmatter.id;
|
|
4275
2355
|
if (!id) continue;
|
|
4276
|
-
pathById.set(id,
|
|
2356
|
+
pathById.set(id, path2.relative(storageDir, mem.path));
|
|
4277
2357
|
}
|
|
4278
2358
|
return pathById;
|
|
4279
2359
|
}
|
|
@@ -4281,7 +2361,7 @@ function appendMemoryToGraphContext(options) {
|
|
|
4281
2361
|
if (!Array.isArray(options.allMemsForGraph)) return;
|
|
4282
2362
|
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
4283
2363
|
options.allMemsForGraph.push({
|
|
4284
|
-
path:
|
|
2364
|
+
path: path2.join(options.storageDir, options.memoryRelPath),
|
|
4285
2365
|
content: options.content,
|
|
4286
2366
|
frontmatter: {
|
|
4287
2367
|
id: options.memoryId,
|
|
@@ -4301,16 +2381,16 @@ function resolvePersistedMemoryRelativePath(options) {
|
|
|
4301
2381
|
const persisted = options.pathById.get(options.memoryId);
|
|
4302
2382
|
if (persisted) return persisted;
|
|
4303
2383
|
if (options.category === "correction") {
|
|
4304
|
-
return
|
|
2384
|
+
return path2.join("corrections", `${options.memoryId}.md`);
|
|
4305
2385
|
}
|
|
4306
2386
|
const subtree = categoryDirName(options.category);
|
|
4307
2387
|
const idParts = options.memoryId.split("-");
|
|
4308
2388
|
const maybeTimestamp = Number(idParts[1]);
|
|
4309
2389
|
if (Number.isFinite(maybeTimestamp) && maybeTimestamp > 0) {
|
|
4310
2390
|
const day = new Date(maybeTimestamp).toISOString().slice(0, 10);
|
|
4311
|
-
return
|
|
2391
|
+
return path2.join(subtree, day, `${options.memoryId}.md`);
|
|
4312
2392
|
}
|
|
4313
|
-
return
|
|
2393
|
+
return path2.join(subtree, `${options.memoryId}.md`);
|
|
4314
2394
|
}
|
|
4315
2395
|
function qmdMaintenanceSkipReasonForError(error) {
|
|
4316
2396
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -4745,7 +2825,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
4745
2825
|
const defaultNs = normalizeNamespaceIdentity(this.config.defaultNamespace);
|
|
4746
2826
|
if (ns !== defaultNs && !isSafeRouteNamespace(ns)) return;
|
|
4747
2827
|
if (!this.storageDirMatchesNamespaceHint(ns, storageDir)) return;
|
|
4748
|
-
const resolvedStorageDir =
|
|
2828
|
+
const resolvedStorageDir = path2.resolve(storageDir);
|
|
4749
2829
|
let hints = this.namespaceStorageDirHints.get(resolvedStorageDir);
|
|
4750
2830
|
if (!hints) {
|
|
4751
2831
|
hints = /* @__PURE__ */ new Set();
|
|
@@ -4756,21 +2836,21 @@ var Orchestrator = class _Orchestrator {
|
|
|
4756
2836
|
storageDirMatchesNamespaceHint(namespace, storageDir) {
|
|
4757
2837
|
const ns = normalizeNamespaceIdentity(namespace);
|
|
4758
2838
|
if (!ns) return false;
|
|
4759
|
-
const resolvedStorageDir =
|
|
4760
|
-
const resolvedMemoryDir =
|
|
2839
|
+
const resolvedStorageDir = path2.resolve(storageDir);
|
|
2840
|
+
const resolvedMemoryDir = path2.resolve(this.config.memoryDir);
|
|
4761
2841
|
const defaultNs = normalizeNamespaceIdentity(this.config.defaultNamespace);
|
|
4762
2842
|
if (resolvedStorageDir === resolvedMemoryDir) return ns === defaultNs;
|
|
4763
|
-
const resolvedNamespacesDir =
|
|
2843
|
+
const resolvedNamespacesDir = path2.join(resolvedMemoryDir, "namespaces");
|
|
4764
2844
|
if (!isPathInsideStorageRoot(resolvedNamespacesDir, resolvedStorageDir)) return false;
|
|
4765
|
-
const rawRoot =
|
|
4766
|
-
const tokenRoot =
|
|
2845
|
+
const rawRoot = path2.resolve(resolvedNamespacesDir, ns);
|
|
2846
|
+
const tokenRoot = path2.resolve(resolvedNamespacesDir, namespaceIdentityToken(ns));
|
|
4767
2847
|
return resolvedStorageDir === rawRoot || resolvedStorageDir === tokenRoot;
|
|
4768
2848
|
}
|
|
4769
2849
|
namespaceStorageDirHintOwnershipRank(record, resolvedStorageDir, configured) {
|
|
4770
|
-
if (resolvedStorageDir ===
|
|
2850
|
+
if (resolvedStorageDir === path2.resolve(this.config.memoryDir)) {
|
|
4771
2851
|
return record.namespace === normalizeNamespaceIdentity(this.config.defaultNamespace) ? 0 : 3;
|
|
4772
2852
|
}
|
|
4773
|
-
const leaf =
|
|
2853
|
+
const leaf = path2.basename(resolvedStorageDir);
|
|
4774
2854
|
const tokenOwnsRoot = namespaceIdentityToken(record.namespace) === leaf;
|
|
4775
2855
|
if (tokenOwnsRoot && configured.has(record.namespace)) return 0;
|
|
4776
2856
|
if (record.namespace === leaf) return 1;
|
|
@@ -4798,7 +2878,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
4798
2878
|
loadNamespaceStorageDirHintsFromCatalog() {
|
|
4799
2879
|
if (this.namespaceStorageDirHintsLoaded || !this.namespaceCatalog.enabled) return;
|
|
4800
2880
|
this.namespaceStorageDirHintsLoaded = true;
|
|
4801
|
-
const catalogPath =
|
|
2881
|
+
const catalogPath = path2.join(this.config.memoryDir, "state", "namespaces.jsonl");
|
|
4802
2882
|
if (!existsSync(catalogPath)) return;
|
|
4803
2883
|
let body;
|
|
4804
2884
|
try {
|
|
@@ -4835,7 +2915,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
4835
2915
|
if (!this.storageDirMatchesNamespaceHint(record.namespace, record.storageDir)) {
|
|
4836
2916
|
continue;
|
|
4837
2917
|
}
|
|
4838
|
-
const resolvedStorageDir =
|
|
2918
|
+
const resolvedStorageDir = path2.resolve(record.storageDir);
|
|
4839
2919
|
const current = preferredByStorageDir.get(resolvedStorageDir);
|
|
4840
2920
|
preferredByStorageDir.set(
|
|
4841
2921
|
resolvedStorageDir,
|
|
@@ -4874,6 +2954,36 @@ var Orchestrator = class _Orchestrator {
|
|
|
4874
2954
|
});
|
|
4875
2955
|
return plan.namespaces.map((candidate) => candidate.namespace);
|
|
4876
2956
|
}
|
|
2957
|
+
/**
|
|
2958
|
+
* Fan out a maintenance job across all maintained namespaces (issue #1500).
|
|
2959
|
+
*
|
|
2960
|
+
* Delegates to the namespace-maintenance-fanout coordinator, which plans
|
|
2961
|
+
* namespace discovery (configured + catalog), applies the cycle budget,
|
|
2962
|
+
* acquires per-job+namespace locks, records status files, and touches the
|
|
2963
|
+
* catalog's `lastMaintenanceAt`. The runner receives a per-namespace
|
|
2964
|
+
* candidate and a storage resolver wired to `this.getStorage(namespace)`.
|
|
2965
|
+
*
|
|
2966
|
+
* When namespaces are disabled the planner returns only the default
|
|
2967
|
+
* namespace, so the job runs exactly once — preserving single-user behavior.
|
|
2968
|
+
*/
|
|
2969
|
+
async runNamespaceMaintenanceFanoutForJob(jobName, runner, options = {}) {
|
|
2970
|
+
return runNamespaceMaintenanceFanout({
|
|
2971
|
+
config: this.config,
|
|
2972
|
+
catalog: this.namespaceCatalog,
|
|
2973
|
+
jobName,
|
|
2974
|
+
runner,
|
|
2975
|
+
resolveStorage: (namespace) => this.getStorage(namespace),
|
|
2976
|
+
enabled: options.enabled
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Read-only namespace maintenance health summary for doctor / dashboard /
|
|
2981
|
+
* CLI (issue #1500). Aggregates all per-namespace maintenance status files
|
|
2982
|
+
* into one report without running any maintenance.
|
|
2983
|
+
*/
|
|
2984
|
+
async readNamespaceMaintenanceHealth() {
|
|
2985
|
+
return summarizeNamespaceMaintenanceHealth(this.config);
|
|
2986
|
+
}
|
|
4877
2987
|
buildConfiguredQmdSearchOptions(queryText) {
|
|
4878
2988
|
const intentHint = this.config.qmdIntentHintsEnabled ? buildQmdIntentHint(inferIntentFromText(queryText)) : void 0;
|
|
4879
2989
|
const explain = this.config.qmdExplainEnabled === true;
|
|
@@ -5002,7 +3112,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5002
3112
|
this.config = config;
|
|
5003
3113
|
this.profiler = new ProfilingCollector({
|
|
5004
3114
|
enabled: config.profilingEnabled,
|
|
5005
|
-
storageDir: config.profilingStorageDir ||
|
|
3115
|
+
storageDir: config.profilingStorageDir || path2.join(config.memoryDir, "profiling"),
|
|
5006
3116
|
maxTraces: config.profilingMaxTraces
|
|
5007
3117
|
});
|
|
5008
3118
|
this.namespaceCatalog = new NamespaceCatalog(config);
|
|
@@ -5048,7 +3158,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5048
3158
|
this.compounding = config.compoundingEnabled ? new CompoundingEngine(config, this.storage) : void 0;
|
|
5049
3159
|
this.buffer = new SmartBuffer(config, this.storage);
|
|
5050
3160
|
this.transcript = new TranscriptManager(config);
|
|
5051
|
-
this.conversationIndexDir =
|
|
3161
|
+
this.conversationIndexDir = path2.join(
|
|
5052
3162
|
config.memoryDir,
|
|
5053
3163
|
"conversation-index",
|
|
5054
3164
|
"chunks"
|
|
@@ -5105,7 +3215,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5105
3215
|
this.modelRegistry
|
|
5106
3216
|
);
|
|
5107
3217
|
this.threading = new ThreadingManager(
|
|
5108
|
-
|
|
3218
|
+
path2.join(config.memoryDir, "threads"),
|
|
5109
3219
|
config.threadingGapMinutes
|
|
5110
3220
|
);
|
|
5111
3221
|
this.tmtBuilder = new TmtBuilder(config.memoryDir, {
|
|
@@ -5184,7 +3294,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5184
3294
|
utilityPromoteThresholdDelta: this.utilityRuntimeValues?.promoteThresholdDelta ?? 0,
|
|
5185
3295
|
utilityDemoteThresholdDelta: this.utilityRuntimeValues?.demoteThresholdDelta ?? 0
|
|
5186
3296
|
};
|
|
5187
|
-
return
|
|
3297
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 12);
|
|
5188
3298
|
}
|
|
5189
3299
|
effectiveLifecycleThresholds() {
|
|
5190
3300
|
const archiveDecayThreshold = this.config.lifecycleArchiveDecayThreshold;
|
|
@@ -5404,13 +3514,13 @@ var Orchestrator = class _Orchestrator {
|
|
|
5404
3514
|
if (this.config.compactionResetEnabled) {
|
|
5405
3515
|
try {
|
|
5406
3516
|
const wsDir = this.config.workspaceDir || defaultWorkspaceDir();
|
|
5407
|
-
const files = await
|
|
3517
|
+
const files = await readdir(wsDir).catch(() => []);
|
|
5408
3518
|
for (const f of files) {
|
|
5409
3519
|
if (!f.startsWith(".compaction-reset-signal-")) continue;
|
|
5410
|
-
const fp =
|
|
5411
|
-
const s = await
|
|
3520
|
+
const fp = path2.join(wsDir, f);
|
|
3521
|
+
const s = await stat(fp).catch(() => null);
|
|
5412
3522
|
if (s && Date.now() - s.mtimeMs >= COMPACTION_SIGNAL_MAX_AGE_MS) {
|
|
5413
|
-
await
|
|
3523
|
+
await unlink(fp).catch(() => {
|
|
5414
3524
|
});
|
|
5415
3525
|
log.debug(`initialize: removed stale compaction signal ${f}`);
|
|
5416
3526
|
}
|
|
@@ -5686,9 +3796,9 @@ var Orchestrator = class _Orchestrator {
|
|
|
5686
3796
|
`wearables auto-sync started: every ${this.config.wearables.autoSyncIntervalMinutes}m over ${this.config.wearables.autoSyncDays}d (deep ${this.config.wearables.autoSyncDeepDays}d daily)`
|
|
5687
3797
|
);
|
|
5688
3798
|
} catch (err) {
|
|
5689
|
-
const { displayErrorDetail
|
|
3799
|
+
const { displayErrorDetail } = await import("./runtime/better-sqlite.js");
|
|
5690
3800
|
log.warn(
|
|
5691
|
-
`wearables auto-sync failed to start (non-fatal): ${
|
|
3801
|
+
`wearables auto-sync failed to start (non-fatal): ${displayErrorDetail(err)}`
|
|
5692
3802
|
);
|
|
5693
3803
|
}
|
|
5694
3804
|
}
|
|
@@ -5801,7 +3911,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5801
3911
|
*/
|
|
5802
3912
|
async autoRegisterDaySummaryCron() {
|
|
5803
3913
|
const home = resolveHomeDir();
|
|
5804
|
-
const jobsPath =
|
|
3914
|
+
const jobsPath = path2.join(home, ".openclaw", "cron", "jobs.json");
|
|
5805
3915
|
try {
|
|
5806
3916
|
if (!existsSync(jobsPath)) {
|
|
5807
3917
|
log.debug(
|
|
@@ -5854,7 +3964,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5854
3964
|
}
|
|
5855
3965
|
async autoRegisterNightlyGovernanceCron() {
|
|
5856
3966
|
const home = resolveHomeDir();
|
|
5857
|
-
const jobsPath =
|
|
3967
|
+
const jobsPath = path2.join(home, ".openclaw", "cron", "jobs.json");
|
|
5858
3968
|
try {
|
|
5859
3969
|
if (!existsSync(jobsPath)) {
|
|
5860
3970
|
log.debug("nightly governance cron: jobs.json not found, skipping auto-register");
|
|
@@ -5876,7 +3986,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5876
3986
|
}
|
|
5877
3987
|
async autoRegisterProceduralMiningCron() {
|
|
5878
3988
|
const home = resolveHomeDir();
|
|
5879
|
-
const jobsPath =
|
|
3989
|
+
const jobsPath = path2.join(home, ".openclaw", "cron", "jobs.json");
|
|
5880
3990
|
try {
|
|
5881
3991
|
if (!existsSync(jobsPath)) {
|
|
5882
3992
|
log.debug("procedural mining cron: jobs.json not found, skipping auto-register");
|
|
@@ -5896,7 +4006,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5896
4006
|
}
|
|
5897
4007
|
async autoRegisterContradictionScanCron() {
|
|
5898
4008
|
const home = resolveHomeDir();
|
|
5899
|
-
const jobsPath =
|
|
4009
|
+
const jobsPath = path2.join(home, ".openclaw", "cron", "jobs.json");
|
|
5900
4010
|
try {
|
|
5901
4011
|
if (!existsSync(jobsPath)) {
|
|
5902
4012
|
log.debug("contradiction scan cron: jobs.json not found, skipping auto-register");
|
|
@@ -5916,7 +4026,7 @@ var Orchestrator = class _Orchestrator {
|
|
|
5916
4026
|
}
|
|
5917
4027
|
async autoRegisterPatternReinforcementCron() {
|
|
5918
4028
|
const home = resolveHomeDir();
|
|
5919
|
-
const jobsPath =
|
|
4029
|
+
const jobsPath = path2.join(home, ".openclaw", "cron", "jobs.json");
|
|
5920
4030
|
try {
|
|
5921
4031
|
if (!existsSync(jobsPath)) {
|
|
5922
4032
|
log.debug("pattern reinforcement cron: jobs.json not found, skipping auto-register");
|
|
@@ -5976,9 +4086,92 @@ var Orchestrator = class _Orchestrator {
|
|
|
5976
4086
|
);
|
|
5977
4087
|
return { ran: true, result, namespace: cadenceKey };
|
|
5978
4088
|
}
|
|
4089
|
+
/**
|
|
4090
|
+
* Fan out pattern reinforcement across all maintained namespaces (issue #1500).
|
|
4091
|
+
* Delegates per-namespace execution to {@link runPatternReinforcement} while
|
|
4092
|
+
* the planner handles discovery, budgeting, locking, and status recording.
|
|
4093
|
+
* When namespaces are disabled, runs once against default storage.
|
|
4094
|
+
*/
|
|
4095
|
+
async runPatternReinforcementFanout(options = {}) {
|
|
4096
|
+
return this.runNamespaceMaintenanceFanoutForJob(
|
|
4097
|
+
"pattern-reinforcement",
|
|
4098
|
+
async (ctx) => {
|
|
4099
|
+
const result = await this.runPatternReinforcement({
|
|
4100
|
+
namespace: ctx.candidate.namespace,
|
|
4101
|
+
force: options.force
|
|
4102
|
+
});
|
|
4103
|
+
if (!result.ran) {
|
|
4104
|
+
return {
|
|
4105
|
+
skipped: true,
|
|
4106
|
+
skipReason: result.skippedReason ?? "throttled"
|
|
4107
|
+
};
|
|
4108
|
+
}
|
|
4109
|
+
return result.result ? { itemCount: result.result.clustersFound } : { itemCount: 0 };
|
|
4110
|
+
},
|
|
4111
|
+
{ enabled: this.config.patternReinforcementEnabled }
|
|
4112
|
+
);
|
|
4113
|
+
}
|
|
4114
|
+
/**
|
|
4115
|
+
* Fan out lifecycle/governance policy across all maintained namespaces
|
|
4116
|
+
* (issue #1500). Each namespace gets its own lifecycle pass against its
|
|
4117
|
+
* namespace-scoped storage. When namespaces are disabled, runs once against
|
|
4118
|
+
* default storage.
|
|
4119
|
+
*/
|
|
4120
|
+
async runLifecyclePolicyFanout() {
|
|
4121
|
+
return this.runNamespaceMaintenanceFanoutForJob(
|
|
4122
|
+
"lifecycle",
|
|
4123
|
+
async (ctx) => {
|
|
4124
|
+
const storage = await this.getStorage(ctx.candidate.namespace);
|
|
4125
|
+
const corpus = await storage.readAllMemories();
|
|
4126
|
+
const assessed = await this.runLifecyclePolicyPass(corpus, storage);
|
|
4127
|
+
return { itemCount: assessed };
|
|
4128
|
+
},
|
|
4129
|
+
{ enabled: this.config.lifecyclePolicyEnabled }
|
|
4130
|
+
);
|
|
4131
|
+
}
|
|
4132
|
+
/**
|
|
4133
|
+
* Fan out semantic consolidation across all maintained namespaces (issue #1500).
|
|
4134
|
+
* Each namespace gets its own consolidation pass against its namespace-scoped
|
|
4135
|
+
* storage. When namespaces are disabled, runs once against default storage.
|
|
4136
|
+
*/
|
|
4137
|
+
async runSemanticConsolidationFanout(options = {}) {
|
|
4138
|
+
return this.runNamespaceMaintenanceFanoutForJob(
|
|
4139
|
+
"semantic-consolidation",
|
|
4140
|
+
async (ctx) => {
|
|
4141
|
+
const storage = await this.getStorage(ctx.candidate.namespace);
|
|
4142
|
+
const result = await this.runSemanticConsolidation({
|
|
4143
|
+
dryRun: options.dryRun,
|
|
4144
|
+
thresholdOverride: void 0,
|
|
4145
|
+
force: true,
|
|
4146
|
+
storage
|
|
4147
|
+
});
|
|
4148
|
+
return { itemCount: result.clustersFound };
|
|
4149
|
+
},
|
|
4150
|
+
{ enabled: this.config.semanticConsolidationEnabled }
|
|
4151
|
+
);
|
|
4152
|
+
}
|
|
4153
|
+
/**
|
|
4154
|
+
* Fan out deep-sleep governance across all maintained namespaces (issue #1500).
|
|
4155
|
+
* Each namespace gets its own governance scan against its namespace-scoped
|
|
4156
|
+
* storage. When namespaces are disabled, runs once against default storage.
|
|
4157
|
+
*/
|
|
4158
|
+
async runDeepSleepGovernanceFanout(options = {}) {
|
|
4159
|
+
return this.runNamespaceMaintenanceFanoutForJob(
|
|
4160
|
+
"governance",
|
|
4161
|
+
async (ctx) => {
|
|
4162
|
+
const storage = await this.getStorage(ctx.candidate.namespace);
|
|
4163
|
+
const result = await this.runDeepSleepGovernanceNow({
|
|
4164
|
+
dryRun: options.dryRun,
|
|
4165
|
+
storage
|
|
4166
|
+
});
|
|
4167
|
+
return { itemCount: result.scannedMemories };
|
|
4168
|
+
},
|
|
4169
|
+
{ enabled: this.config.dreamsPhases.deepSleep.enabled }
|
|
4170
|
+
);
|
|
4171
|
+
}
|
|
5979
4172
|
async autoRegisterGraphEdgeDecayCron() {
|
|
5980
4173
|
const home = resolveHomeDir();
|
|
5981
|
-
const jobsPath =
|
|
4174
|
+
const jobsPath = path2.join(home, ".openclaw", "cron", "jobs.json");
|
|
5982
4175
|
try {
|
|
5983
4176
|
if (!existsSync(jobsPath)) {
|
|
5984
4177
|
log.debug("graph edge decay cron: jobs.json not found, skipping auto-register");
|
|
@@ -6035,15 +4228,15 @@ ${doc.content}` : doc.content,
|
|
|
6035
4228
|
this.lastFileHygieneRunAtMs = now;
|
|
6036
4229
|
if (hygiene.rotateEnabled) {
|
|
6037
4230
|
for (const rel of hygiene.rotatePaths) {
|
|
6038
|
-
const abs =
|
|
4231
|
+
const abs = path2.isAbsolute(rel) ? rel : path2.join(this.config.workspaceDir, rel);
|
|
6039
4232
|
try {
|
|
6040
|
-
const raw = await
|
|
4233
|
+
const raw = await readFile2(abs, "utf-8");
|
|
6041
4234
|
if (raw.length > hygiene.rotateMaxBytes) {
|
|
6042
|
-
const archiveDir =
|
|
4235
|
+
const archiveDir = path2.join(
|
|
6043
4236
|
this.config.workspaceDir,
|
|
6044
4237
|
hygiene.archiveDir
|
|
6045
4238
|
);
|
|
6046
|
-
const base =
|
|
4239
|
+
const base = path2.basename(abs);
|
|
6047
4240
|
const prefix = base.toUpperCase().replace(/\.MD$/i, "").replace(/[^A-Z0-9]+/g, "-") || "FILE";
|
|
6048
4241
|
const { newContent } = await rotateMarkdownFileToArchive({
|
|
6049
4242
|
filePath: abs,
|
|
@@ -6051,7 +4244,7 @@ ${doc.content}` : doc.content,
|
|
|
6051
4244
|
archivePrefix: prefix,
|
|
6052
4245
|
keepTailChars: hygiene.rotateKeepTailChars
|
|
6053
4246
|
});
|
|
6054
|
-
await
|
|
4247
|
+
await writeFile2(abs, newContent, "utf-8");
|
|
6055
4248
|
}
|
|
6056
4249
|
} catch {
|
|
6057
4250
|
}
|
|
@@ -6068,8 +4261,8 @@ ${doc.content}` : doc.content,
|
|
|
6068
4261
|
log.warn(w.message);
|
|
6069
4262
|
}
|
|
6070
4263
|
if (hygiene.warningsLogEnabled && warnings.length > 0) {
|
|
6071
|
-
const fp =
|
|
6072
|
-
await
|
|
4264
|
+
const fp = path2.join(this.config.memoryDir, hygiene.warningsLogPath);
|
|
4265
|
+
await mkdir2(path2.dirname(fp), { recursive: true });
|
|
6073
4266
|
const stamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
6074
4267
|
const block = `
|
|
6075
4268
|
|
|
@@ -6078,11 +4271,11 @@ ${doc.content}` : doc.content,
|
|
|
6078
4271
|
` + warnings.map((w) => `- ${w.message}`).join("\n") + "\n";
|
|
6079
4272
|
let existing = "";
|
|
6080
4273
|
try {
|
|
6081
|
-
existing = await
|
|
4274
|
+
existing = await readFile2(fp, "utf-8");
|
|
6082
4275
|
} catch {
|
|
6083
4276
|
existing = "# Engram File Hygiene Warnings\n";
|
|
6084
4277
|
}
|
|
6085
|
-
await
|
|
4278
|
+
await writeFile2(fp, existing + block, "utf-8");
|
|
6086
4279
|
}
|
|
6087
4280
|
}
|
|
6088
4281
|
}
|
|
@@ -6547,26 +4740,26 @@ ${evidenceText}`
|
|
|
6547
4740
|
const facts = [];
|
|
6548
4741
|
let memoryRootReal = null;
|
|
6549
4742
|
try {
|
|
6550
|
-
memoryRootReal = await
|
|
4743
|
+
memoryRootReal = await realpath(storage.dir);
|
|
6551
4744
|
} catch {
|
|
6552
4745
|
memoryRootReal = null;
|
|
6553
4746
|
}
|
|
6554
4747
|
for (const categoryDir of RECALL_FALLBACK_DIRS) {
|
|
6555
4748
|
if (memoryRootReal === null) break;
|
|
6556
4749
|
for (const date of datesToScan) {
|
|
6557
|
-
const dateDir =
|
|
4750
|
+
const dateDir = path2.join(storage.dir, categoryDir, date);
|
|
6558
4751
|
try {
|
|
6559
|
-
const dirStat = await
|
|
4752
|
+
const dirStat = await lstat(dateDir);
|
|
6560
4753
|
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) continue;
|
|
6561
|
-
assertPathInsideRoot(memoryRootReal, await
|
|
6562
|
-
const entries = await
|
|
4754
|
+
assertPathInsideRoot(memoryRootReal, await realpath(dateDir), dateDir);
|
|
4755
|
+
const entries = await readdir(dateDir, { withFileTypes: true });
|
|
6563
4756
|
for (const entry of entries) {
|
|
6564
4757
|
if (entry.isSymbolicLink()) continue;
|
|
6565
4758
|
if (!entry.name.endsWith(".md")) continue;
|
|
6566
|
-
const fullPath =
|
|
4759
|
+
const fullPath = path2.join(dateDir, entry.name);
|
|
6567
4760
|
try {
|
|
6568
|
-
assertPathInsideRoot(memoryRootReal, await
|
|
6569
|
-
const raw = await
|
|
4761
|
+
assertPathInsideRoot(memoryRootReal, await realpath(fullPath), fullPath);
|
|
4762
|
+
const raw = await readFile2(fullPath, "utf-8");
|
|
6570
4763
|
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
6571
4764
|
if (!fmMatch) continue;
|
|
6572
4765
|
const fmBlock = fmMatch[1];
|
|
@@ -6585,7 +4778,7 @@ ${evidenceText}`
|
|
|
6585
4778
|
facts.push({
|
|
6586
4779
|
path: fullPath,
|
|
6587
4780
|
frontmatter: {
|
|
6588
|
-
id: fm.id ||
|
|
4781
|
+
id: fm.id || path2.basename(entry.name, ".md"),
|
|
6589
4782
|
category: fm.category || "fact",
|
|
6590
4783
|
created,
|
|
6591
4784
|
updated: fm.updated || created,
|
|
@@ -6608,15 +4801,15 @@ ${evidenceText}`
|
|
|
6608
4801
|
return a.frontmatter.created < b.frontmatter.created ? -1 : 1;
|
|
6609
4802
|
});
|
|
6610
4803
|
const hourlySummaries = [];
|
|
6611
|
-
const hourlyBaseDir =
|
|
4804
|
+
const hourlyBaseDir = path2.join(storage.dir, "summaries", "hourly");
|
|
6612
4805
|
try {
|
|
6613
|
-
const sessionKeys = await
|
|
4806
|
+
const sessionKeys = await readdir(hourlyBaseDir, { withFileTypes: true });
|
|
6614
4807
|
for (const sk of sessionKeys) {
|
|
6615
4808
|
if (!sk.isDirectory()) continue;
|
|
6616
4809
|
for (const date of datesToScan) {
|
|
6617
|
-
const summaryFile =
|
|
4810
|
+
const summaryFile = path2.join(hourlyBaseDir, sk.name, `${date}.md`);
|
|
6618
4811
|
try {
|
|
6619
|
-
const raw = await
|
|
4812
|
+
const raw = await readFile2(summaryFile, "utf-8");
|
|
6620
4813
|
const filtered = filterHourlySummaryMarkdownForLocalDay(
|
|
6621
4814
|
raw,
|
|
6622
4815
|
date,
|
|
@@ -6718,13 +4911,13 @@ ${evidenceText}`
|
|
|
6718
4911
|
}
|
|
6719
4912
|
async getLastGraphRecallSnapshot(namespace) {
|
|
6720
4913
|
const storage = await this.getStorage(namespace);
|
|
6721
|
-
const snapshotPath =
|
|
4914
|
+
const snapshotPath = path2.join(
|
|
6722
4915
|
storage.dir,
|
|
6723
4916
|
"state",
|
|
6724
4917
|
"last_graph_recall.json"
|
|
6725
4918
|
);
|
|
6726
4919
|
try {
|
|
6727
|
-
const raw = await
|
|
4920
|
+
const raw = await readFile2(snapshotPath, "utf-8");
|
|
6728
4921
|
const parsed = JSON.parse(raw);
|
|
6729
4922
|
if (!parsed || typeof parsed !== "object") return null;
|
|
6730
4923
|
return {
|
|
@@ -6757,9 +4950,9 @@ ${evidenceText}`
|
|
|
6757
4950
|
}
|
|
6758
4951
|
async getLastIntentSnapshot(namespace) {
|
|
6759
4952
|
const storage = await this.getStorage(namespace);
|
|
6760
|
-
const snapshotPath =
|
|
4953
|
+
const snapshotPath = path2.join(storage.dir, "state", "last_intent.json");
|
|
6761
4954
|
try {
|
|
6762
|
-
const raw = await
|
|
4955
|
+
const raw = await readFile2(snapshotPath, "utf-8");
|
|
6763
4956
|
const parsed = JSON.parse(raw);
|
|
6764
4957
|
if (!parsed || typeof parsed !== "object") return null;
|
|
6765
4958
|
const graphDecision = parsed.graphDecision && typeof parsed.graphDecision === "object" ? parsed.graphDecision : void 0;
|
|
@@ -6790,13 +4983,13 @@ ${evidenceText}`
|
|
|
6790
4983
|
}
|
|
6791
4984
|
async getLastQmdRecallSnapshot(namespace) {
|
|
6792
4985
|
const storage = await this.getStorage(namespace);
|
|
6793
|
-
const snapshotPath =
|
|
4986
|
+
const snapshotPath = path2.join(
|
|
6794
4987
|
storage.dir,
|
|
6795
4988
|
"state",
|
|
6796
4989
|
"last_qmd_recall.json"
|
|
6797
4990
|
);
|
|
6798
4991
|
try {
|
|
6799
|
-
const raw = await
|
|
4992
|
+
const raw = await readFile2(snapshotPath, "utf-8");
|
|
6800
4993
|
const parsed = JSON.parse(raw);
|
|
6801
4994
|
if (!parsed || typeof parsed !== "object") return null;
|
|
6802
4995
|
return {
|
|
@@ -6940,10 +5133,10 @@ ${r.snippet.trim()}
|
|
|
6940
5133
|
}
|
|
6941
5134
|
async countConversationChunkDocs(dir) {
|
|
6942
5135
|
try {
|
|
6943
|
-
const entries = await
|
|
5136
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
6944
5137
|
let total = 0;
|
|
6945
5138
|
for (const entry of entries) {
|
|
6946
|
-
const fullPath =
|
|
5139
|
+
const fullPath = path2.join(dir, entry.name);
|
|
6947
5140
|
if (entry.isDirectory()) {
|
|
6948
5141
|
total += await this.countConversationChunkDocs(fullPath);
|
|
6949
5142
|
continue;
|
|
@@ -7667,7 +5860,7 @@ ${r.snippet.trim()}
|
|
|
7667
5860
|
if (!options.onDebugSnapshot) return;
|
|
7668
5861
|
await options.onDebugSnapshot({
|
|
7669
5862
|
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7670
|
-
queryHash:
|
|
5863
|
+
queryHash: createHash("sha256").update(prompt).digest("hex"),
|
|
7671
5864
|
queryLength: prompt.length,
|
|
7672
5865
|
collection: options.collection,
|
|
7673
5866
|
namespaces: options.recallNamespaces,
|
|
@@ -7849,7 +6042,7 @@ ${r.snippet.trim()}
|
|
|
7849
6042
|
resolvedPath = resolvedCold.result.path;
|
|
7850
6043
|
resolvedResult = resolvedCold.result;
|
|
7851
6044
|
}
|
|
7852
|
-
if (!
|
|
6045
|
+
if (!path2.isAbsolute(resolvedPath)) {
|
|
7853
6046
|
resolvedAmbiguousSeeds.set(result.path, null);
|
|
7854
6047
|
return null;
|
|
7855
6048
|
}
|
|
@@ -7874,7 +6067,7 @@ ${r.snippet.trim()}
|
|
|
7874
6067
|
}
|
|
7875
6068
|
continue;
|
|
7876
6069
|
}
|
|
7877
|
-
if (
|
|
6070
|
+
if (path2.isAbsolute(result.path)) {
|
|
7878
6071
|
const resolved = await resolveAmbiguousSeedOwner(result, null);
|
|
7879
6072
|
if (resolved) {
|
|
7880
6073
|
addResultForNamespace(resolved.namespace, resolved.result);
|
|
@@ -7917,7 +6110,7 @@ ${r.snippet.trim()}
|
|
|
7917
6110
|
0
|
|
7918
6111
|
);
|
|
7919
6112
|
seedPaths.push(
|
|
7920
|
-
...seedRelativePaths.map((rel) =>
|
|
6113
|
+
...seedRelativePaths.map((rel) => path2.join(storage.dir, rel))
|
|
7921
6114
|
);
|
|
7922
6115
|
const seedSet = new Set(seedRelativePaths);
|
|
7923
6116
|
const expanded = await this.graphIndexFor(storage).spreadingActivation(
|
|
@@ -7933,7 +6126,7 @@ ${r.snippet.trim()}
|
|
|
7933
6126
|
for (const candidate of expanded.slice(0, perNamespaceExpandedCap)) {
|
|
7934
6127
|
if (deadlineExpired()) break;
|
|
7935
6128
|
if (seedSet.has(candidate.path)) continue;
|
|
7936
|
-
const memoryPath =
|
|
6129
|
+
const memoryPath = path2.resolve(storage.dir, candidate.path);
|
|
7937
6130
|
const memory = await storage.readMemoryByPath(memoryPath);
|
|
7938
6131
|
if (deadlineExpired()) break;
|
|
7939
6132
|
if (!memory) continue;
|
|
@@ -7958,7 +6151,7 @@ ${r.snippet.trim()}
|
|
|
7958
6151
|
path: memory.path,
|
|
7959
6152
|
score,
|
|
7960
6153
|
namespace,
|
|
7961
|
-
seed:
|
|
6154
|
+
seed: path2.resolve(storage.dir, candidate.seed),
|
|
7962
6155
|
hopDepth: candidate.hopDepth,
|
|
7963
6156
|
decayedWeight: candidate.decayedWeight,
|
|
7964
6157
|
graphType: candidate.graphType,
|
|
@@ -7979,12 +6172,12 @@ ${r.snippet.trim()}
|
|
|
7979
6172
|
}
|
|
7980
6173
|
async recordLastGraphRecallSnapshot(options) {
|
|
7981
6174
|
try {
|
|
7982
|
-
const snapshotPath =
|
|
6175
|
+
const snapshotPath = path2.join(
|
|
7983
6176
|
options.storage.dir,
|
|
7984
6177
|
"state",
|
|
7985
6178
|
"last_graph_recall.json"
|
|
7986
6179
|
);
|
|
7987
|
-
await
|
|
6180
|
+
await mkdir2(path2.dirname(snapshotPath), { recursive: true });
|
|
7988
6181
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7989
6182
|
const totalSeedCount = options.seedPaths.length;
|
|
7990
6183
|
const totalExpandedCount = options.expandedPaths.length;
|
|
@@ -7996,7 +6189,7 @@ ${r.snippet.trim()}
|
|
|
7996
6189
|
const payload = {
|
|
7997
6190
|
recordedAt: now,
|
|
7998
6191
|
mode: options.recallMode,
|
|
7999
|
-
queryHash:
|
|
6192
|
+
queryHash: createHash("sha256").update(options.prompt).digest("hex"),
|
|
8000
6193
|
queryLength: options.prompt.length,
|
|
8001
6194
|
namespaces: options.recallNamespaces,
|
|
8002
6195
|
seedCount: totalSeedCount,
|
|
@@ -8011,20 +6204,20 @@ ${r.snippet.trim()}
|
|
|
8011
6204
|
finalResults: (options.finalResults ?? []).slice(0, 64),
|
|
8012
6205
|
shadowComparison: options.shadowComparison
|
|
8013
6206
|
};
|
|
8014
|
-
await
|
|
6207
|
+
await writeFile2(snapshotPath, JSON.stringify(payload, null, 2), "utf-8");
|
|
8015
6208
|
} catch (err) {
|
|
8016
6209
|
log.debug(`last graph recall write failed: ${err}`);
|
|
8017
6210
|
}
|
|
8018
6211
|
}
|
|
8019
6212
|
async recordLastIntentSnapshot(options) {
|
|
8020
6213
|
try {
|
|
8021
|
-
const snapshotPath =
|
|
6214
|
+
const snapshotPath = path2.join(
|
|
8022
6215
|
options.storage.dir,
|
|
8023
6216
|
"state",
|
|
8024
6217
|
"last_intent.json"
|
|
8025
6218
|
);
|
|
8026
|
-
await
|
|
8027
|
-
await
|
|
6219
|
+
await mkdir2(path2.dirname(snapshotPath), { recursive: true });
|
|
6220
|
+
await writeFile2(
|
|
8028
6221
|
snapshotPath,
|
|
8029
6222
|
JSON.stringify(options.snapshot, null, 2),
|
|
8030
6223
|
"utf-8"
|
|
@@ -8035,13 +6228,13 @@ ${r.snippet.trim()}
|
|
|
8035
6228
|
}
|
|
8036
6229
|
async recordLastQmdRecallSnapshot(options) {
|
|
8037
6230
|
try {
|
|
8038
|
-
const snapshotPath =
|
|
6231
|
+
const snapshotPath = path2.join(
|
|
8039
6232
|
options.storage.dir,
|
|
8040
6233
|
"state",
|
|
8041
6234
|
"last_qmd_recall.json"
|
|
8042
6235
|
);
|
|
8043
|
-
await
|
|
8044
|
-
await
|
|
6236
|
+
await mkdir2(path2.dirname(snapshotPath), { recursive: true });
|
|
6237
|
+
await writeFile2(
|
|
8045
6238
|
snapshotPath,
|
|
8046
6239
|
JSON.stringify(options.snapshot, null, 2),
|
|
8047
6240
|
"utf-8"
|
|
@@ -8055,9 +6248,9 @@ ${r.snippet.trim()}
|
|
|
8055
6248
|
const stateDir = await this.resolveStateDirForNamespace(
|
|
8056
6249
|
options.namespace
|
|
8057
6250
|
);
|
|
8058
|
-
const snapshotPath =
|
|
8059
|
-
await
|
|
8060
|
-
await
|
|
6251
|
+
const snapshotPath = path2.join(stateDir, "last_intent.json");
|
|
6252
|
+
await mkdir2(path2.dirname(snapshotPath), { recursive: true });
|
|
6253
|
+
await writeFile2(
|
|
8061
6254
|
snapshotPath,
|
|
8062
6255
|
JSON.stringify(options.snapshot, null, 2),
|
|
8063
6256
|
"utf-8"
|
|
@@ -8068,24 +6261,24 @@ ${r.snippet.trim()}
|
|
|
8068
6261
|
}
|
|
8069
6262
|
async resolveStateDirForNamespace(namespace) {
|
|
8070
6263
|
if (!this.config.namespacesEnabled) {
|
|
8071
|
-
return
|
|
6264
|
+
return path2.join(this.config.memoryDir, "state");
|
|
8072
6265
|
}
|
|
8073
6266
|
if (namespace !== this.config.defaultNamespace) {
|
|
8074
|
-
return
|
|
6267
|
+
return path2.join(this.config.memoryDir, "namespaces", namespace, "state");
|
|
8075
6268
|
}
|
|
8076
|
-
const candidate =
|
|
6269
|
+
const candidate = path2.join(
|
|
8077
6270
|
this.config.memoryDir,
|
|
8078
6271
|
"namespaces",
|
|
8079
6272
|
this.config.defaultNamespace
|
|
8080
6273
|
);
|
|
8081
6274
|
try {
|
|
8082
|
-
const candidateStat = await
|
|
6275
|
+
const candidateStat = await stat(candidate);
|
|
8083
6276
|
if (candidateStat.isDirectory()) {
|
|
8084
|
-
return
|
|
6277
|
+
return path2.join(candidate, "state");
|
|
8085
6278
|
}
|
|
8086
6279
|
} catch {
|
|
8087
6280
|
}
|
|
8088
|
-
return
|
|
6281
|
+
return path2.join(this.config.memoryDir, "state");
|
|
8089
6282
|
}
|
|
8090
6283
|
buildGraphRecallRankedResults(results, sourceLabelResolver, limit = 64) {
|
|
8091
6284
|
return results.slice(0, limit).map((result) => ({
|
|
@@ -8338,8 +6531,8 @@ ${r.snippet.trim()}
|
|
|
8338
6531
|
timings,
|
|
8339
6532
|
logger: log
|
|
8340
6533
|
});
|
|
8341
|
-
const promptHash =
|
|
8342
|
-
const traceId =
|
|
6534
|
+
const promptHash = createHash("sha256").update(prompt).digest("hex");
|
|
6535
|
+
const traceId = createHash("sha256").update(`${sessionKey ?? "default"}:${recallStart}:${promptHash}`).digest("hex").slice(0, 16);
|
|
8343
6536
|
const sectionBuckets = /* @__PURE__ */ new Map();
|
|
8344
6537
|
const queryPolicy = buildRecallQueryPolicy(prompt, sessionKey, {
|
|
8345
6538
|
cronRecallPolicyEnabled: this.config.cronRecallPolicyEnabled,
|
|
@@ -8348,7 +6541,7 @@ ${r.snippet.trim()}
|
|
|
8348
6541
|
cronConversationRecallMode: this.config.cronConversationRecallMode
|
|
8349
6542
|
});
|
|
8350
6543
|
const retrievalQuery = queryPolicy.retrievalQuery || prompt;
|
|
8351
|
-
const retrievalQueryHash =
|
|
6544
|
+
const retrievalQueryHash = createHash("sha256").update(retrievalQuery).digest("hex");
|
|
8352
6545
|
const policyVersion = this.currentPolicyVersion();
|
|
8353
6546
|
let impressionRecorded = false;
|
|
8354
6547
|
let recallSource = "none";
|
|
@@ -8483,7 +6676,7 @@ ${r.snippet.trim()}
|
|
|
8483
6676
|
const graphExpandedResultPaths = /* @__PURE__ */ new Set();
|
|
8484
6677
|
const graphSourceLabelsForPath = (resultPath) => {
|
|
8485
6678
|
const labels = [];
|
|
8486
|
-
const normalizedPath = resultPath.split(
|
|
6679
|
+
const normalizedPath = resultPath.split(path2.sep).join("/");
|
|
8487
6680
|
const isEntityPath = normalizedPath.startsWith("entities/") || normalizedPath.includes("/entities/");
|
|
8488
6681
|
if (graphBaselinePaths.has(resultPath)) labels.push("baseline");
|
|
8489
6682
|
if (graphExpandedResultPaths.has(resultPath))
|
|
@@ -8633,7 +6826,7 @@ ${r.snippet.trim()}
|
|
|
8633
6826
|
profileStorageNamespaces.map((namespace) => this.storageRouter.storageFor(namespace))
|
|
8634
6827
|
);
|
|
8635
6828
|
const emptyProfileStorage = new Proxy(
|
|
8636
|
-
{ dir:
|
|
6829
|
+
{ dir: path2.join(this.config.memoryDir, ".empty-scope-profile") },
|
|
8637
6830
|
{
|
|
8638
6831
|
get(target, prop) {
|
|
8639
6832
|
if (prop in target) return target[prop];
|
|
@@ -10109,16 +8302,16 @@ ${formatted}`;
|
|
|
10109
8302
|
if (!this.config.compactionResetEnabled) return null;
|
|
10110
8303
|
const workspaceDir = compactionWorkspaceDir || this.config.workspaceDir || defaultWorkspaceDir();
|
|
10111
8304
|
const safeSessionKey = sanitizeSessionKeyForFilename(effectiveSessionKey);
|
|
10112
|
-
const signalPath =
|
|
8305
|
+
const signalPath = path2.join(
|
|
10113
8306
|
workspaceDir,
|
|
10114
8307
|
`.compaction-reset-signal-${safeSessionKey}`
|
|
10115
8308
|
);
|
|
10116
|
-
const bootPath =
|
|
8309
|
+
const bootPath = path2.join(workspaceDir, "BOOT.md");
|
|
10117
8310
|
try {
|
|
10118
|
-
const signalStat = await
|
|
8311
|
+
const signalStat = await stat(signalPath).catch(() => null);
|
|
10119
8312
|
if (!signalStat) return null;
|
|
10120
8313
|
const signalAge = Date.now() - signalStat.mtimeMs;
|
|
10121
|
-
const signalData = JSON.parse(await
|
|
8314
|
+
const signalData = JSON.parse(await readFile2(signalPath, "utf-8"));
|
|
10122
8315
|
if (signalData.sessionKey !== effectiveSessionKey) {
|
|
10123
8316
|
log.debug(
|
|
10124
8317
|
`recall: compaction signal is for ${signalData.sessionKey}, not ${effectiveSessionKey} \u2014 skipping`
|
|
@@ -10129,7 +8322,7 @@ ${formatted}`;
|
|
|
10129
8322
|
log.debug(
|
|
10130
8323
|
`recall: stale compaction signal (${Math.round(signalAge / 1e3)}s old), skipping`
|
|
10131
8324
|
);
|
|
10132
|
-
await
|
|
8325
|
+
await unlink(signalPath).catch(() => {
|
|
10133
8326
|
});
|
|
10134
8327
|
return null;
|
|
10135
8328
|
}
|
|
@@ -10138,7 +8331,7 @@ ${formatted}`;
|
|
|
10138
8331
|
|
|
10139
8332
|
`;
|
|
10140
8333
|
try {
|
|
10141
|
-
const bootContent = await
|
|
8334
|
+
const bootContent = await readFile2(bootPath, "utf-8");
|
|
10142
8335
|
section += "### BOOT.md (working state before compaction)\n\n";
|
|
10143
8336
|
section += bootContent + "\n";
|
|
10144
8337
|
} catch {
|
|
@@ -10149,12 +8342,12 @@ ${formatted}`;
|
|
|
10149
8342
|
log.info(
|
|
10150
8343
|
`recall: injected compaction reset context for ${effectiveSessionKey}`
|
|
10151
8344
|
);
|
|
10152
|
-
await
|
|
8345
|
+
await unlink(signalPath).catch(() => {
|
|
10153
8346
|
});
|
|
10154
8347
|
return section;
|
|
10155
8348
|
} catch (err) {
|
|
10156
8349
|
log.debug("recall: compaction signal check failed:", err);
|
|
10157
|
-
await
|
|
8350
|
+
await unlink(signalPath).catch(() => {
|
|
10158
8351
|
});
|
|
10159
8352
|
return null;
|
|
10160
8353
|
}
|
|
@@ -12293,7 +10486,7 @@ _Context: ${topQuestion.context}_`
|
|
|
12293
10486
|
const shouldUseStableBatchKey = turns.some(
|
|
12294
10487
|
(turn) => turn.persistProcessedFingerprint === true || typeof turn.turnFingerprint === "string" && turn.turnFingerprint.length > 0
|
|
12295
10488
|
);
|
|
12296
|
-
const stableBatchFingerprint = shouldUseStableBatchKey ?
|
|
10489
|
+
const stableBatchFingerprint = shouldUseStableBatchKey ? createHash("sha256").update(
|
|
12297
10490
|
turns.map(
|
|
12298
10491
|
(turn) => [
|
|
12299
10492
|
turn.role,
|
|
@@ -12550,7 +10743,7 @@ _Context: ${topQuestion.context}_`
|
|
|
12550
10743
|
buildExtractionFingerprint(turns, bufferKey) {
|
|
12551
10744
|
const normalized = this.normalizeExtractionFingerprintTurns(turns).join("\n");
|
|
12552
10745
|
if (!normalized) return null;
|
|
12553
|
-
return
|
|
10746
|
+
return createHash("sha256").update(`${bufferKey}
|
|
12554
10747
|
${normalized}`).digest("hex");
|
|
12555
10748
|
}
|
|
12556
10749
|
shouldQueueExtraction(turns, options = {}) {
|
|
@@ -13075,7 +11268,7 @@ ${normalized}`).digest("hex");
|
|
|
13075
11268
|
);
|
|
13076
11269
|
this.tierMigrationInFlight = true;
|
|
13077
11270
|
try {
|
|
13078
|
-
const coldStorage = new StorageManager(
|
|
11271
|
+
const coldStorage = new StorageManager(path2.join(storage.dir, "cold"));
|
|
13079
11272
|
const [hotMemories, coldMemories] = await Promise.all([
|
|
13080
11273
|
storage.readAllMemories(),
|
|
13081
11274
|
coldStorage.readAllMemories()
|
|
@@ -14491,7 +12684,7 @@ ${normalized}`).digest("hex");
|
|
|
14491
12684
|
const allMems = allMemsForGraph ?? [];
|
|
14492
12685
|
for (const m of allMems) {
|
|
14493
12686
|
if (m.frontmatter.entityRef === entityRef) {
|
|
14494
|
-
const rel =
|
|
12687
|
+
const rel = path2.relative(storage.dir, m.path);
|
|
14495
12688
|
if (rel !== memoryRelPath) entitySiblings.push(rel);
|
|
14496
12689
|
}
|
|
14497
12690
|
}
|
|
@@ -14813,14 +13006,14 @@ ${normalized}`).digest("hex");
|
|
|
14813
13006
|
}
|
|
14814
13007
|
if (this.config.semanticConsolidationEnabled) {
|
|
14815
13008
|
try {
|
|
14816
|
-
const stateFilePath =
|
|
13009
|
+
const stateFilePath = path2.join(
|
|
14817
13010
|
this.config.memoryDir,
|
|
14818
13011
|
"state",
|
|
14819
13012
|
"semantic-consolidation-last-run.json"
|
|
14820
13013
|
);
|
|
14821
13014
|
let shouldRun = true;
|
|
14822
13015
|
try {
|
|
14823
|
-
const stateRaw = await
|
|
13016
|
+
const stateRaw = await readFile2(stateFilePath, "utf-8");
|
|
14824
13017
|
const stateData = JSON.parse(stateRaw);
|
|
14825
13018
|
if (stateData.lastRunAt) {
|
|
14826
13019
|
const lastRunMs = new Date(stateData.lastRunAt).getTime();
|
|
@@ -14861,9 +13054,9 @@ ${normalized}`).digest("hex");
|
|
|
14861
13054
|
);
|
|
14862
13055
|
}
|
|
14863
13056
|
if (semResult.errors === 0 || semResult.memoriesArchived > 0) {
|
|
14864
|
-
const stateDir =
|
|
14865
|
-
await
|
|
14866
|
-
await
|
|
13057
|
+
const stateDir = path2.join(this.config.memoryDir, "state");
|
|
13058
|
+
await mkdir2(stateDir, { recursive: true });
|
|
13059
|
+
await writeFile2(
|
|
14867
13060
|
stateFilePath,
|
|
14868
13061
|
JSON.stringify({ lastRunAt: (/* @__PURE__ */ new Date()).toISOString() }),
|
|
14869
13062
|
"utf-8"
|
|
@@ -15059,7 +13252,7 @@ ${texts.map((t, i) => `[${i + 1}] ${t}`).join("\n\n")}`;
|
|
|
15059
13252
|
}
|
|
15060
13253
|
});
|
|
15061
13254
|
const content = renderCompressionGuidelinesMarkdown(refinedCandidate);
|
|
15062
|
-
const contentHash =
|
|
13255
|
+
const contentHash = createHash("sha256").update(content).digest("hex");
|
|
15063
13256
|
const semanticRefinementApplied = JSON.stringify(refinedCandidate.ruleUpdates) !== JSON.stringify(candidate.ruleUpdates);
|
|
15064
13257
|
const changedRules = refinedCandidate.ruleUpdates.filter(
|
|
15065
13258
|
(rule) => rule.delta !== 0
|
|
@@ -15361,13 +13554,13 @@ ${texts.map((t, i) => `[${i + 1}] ${t}`).join("\n\n")}`;
|
|
|
15361
13554
|
protectedCategories: this.config.lifecycleProtectedCategories
|
|
15362
13555
|
}
|
|
15363
13556
|
};
|
|
15364
|
-
const metricsPath =
|
|
13557
|
+
const metricsPath = path2.join(
|
|
15365
13558
|
storage.dir,
|
|
15366
13559
|
"state",
|
|
15367
13560
|
"lifecycle-metrics.json"
|
|
15368
13561
|
);
|
|
15369
|
-
await
|
|
15370
|
-
await
|
|
13562
|
+
await mkdir2(path2.dirname(metricsPath), { recursive: true });
|
|
13563
|
+
await writeFile2(metricsPath, JSON.stringify(metrics, null, 2), "utf-8");
|
|
15371
13564
|
return updatedCount;
|
|
15372
13565
|
}
|
|
15373
13566
|
/**
|
|
@@ -15890,7 +14083,7 @@ ${lines.join("\n\n")}`;
|
|
|
15890
14083
|
const seenStorageDirs = /* @__PURE__ */ new Set();
|
|
15891
14084
|
const addStorage = (storage) => {
|
|
15892
14085
|
const storageDir = storageDirFor(storage);
|
|
15893
|
-
const storageKey = storageDir ?
|
|
14086
|
+
const storageKey = storageDir ? path2.resolve(storageDir) : `storage-without-dir-${storages.length}`;
|
|
15894
14087
|
if (seenStorageDirs.has(storageKey)) return;
|
|
15895
14088
|
seenStorageDirs.add(storageKey);
|
|
15896
14089
|
storages.push(storage);
|
|
@@ -15920,7 +14113,7 @@ ${lines.join("\n\n")}`;
|
|
|
15920
14113
|
continue;
|
|
15921
14114
|
}
|
|
15922
14115
|
try {
|
|
15923
|
-
const coldRoot =
|
|
14116
|
+
const coldRoot = path2.join(storageDir, "cold");
|
|
15924
14117
|
for (const candidate of qmdResultPathCandidates(
|
|
15925
14118
|
coldRoot,
|
|
15926
14119
|
parts.relativePath
|
|
@@ -15961,7 +14154,7 @@ ${lines.join("\n\n")}`;
|
|
|
15961
14154
|
return null;
|
|
15962
14155
|
}
|
|
15963
14156
|
}
|
|
15964
|
-
if (
|
|
14157
|
+
if (path2.isAbsolute(resultPath)) {
|
|
15965
14158
|
if (!fallbackStorageDir) {
|
|
15966
14159
|
return await fallbackStorage.readMemoryByPath(resultPath);
|
|
15967
14160
|
}
|
|
@@ -16000,7 +14193,7 @@ ${lines.join("\n\n")}`;
|
|
|
16000
14193
|
);
|
|
16001
14194
|
if (!memory) return null;
|
|
16002
14195
|
let ownerNamespace = null;
|
|
16003
|
-
if (
|
|
14196
|
+
if (path2.isAbsolute(memory.path)) {
|
|
16004
14197
|
const ownerStorage = await this.storageForAbsoluteQmdResultPath(
|
|
16005
14198
|
memory.path,
|
|
16006
14199
|
fallbackStorage,
|
|
@@ -16024,16 +14217,16 @@ ${lines.join("\n\n")}`;
|
|
|
16024
14217
|
};
|
|
16025
14218
|
}
|
|
16026
14219
|
async storageForAbsoluteQmdResultPath(resultPath, fallbackStorage, recallNamespaces = []) {
|
|
16027
|
-
const resolvedPath =
|
|
16028
|
-
const memoryRoot =
|
|
16029
|
-
const namespacesRoot =
|
|
14220
|
+
const resolvedPath = path2.resolve(resultPath);
|
|
14221
|
+
const memoryRoot = path2.resolve(this.config.memoryDir);
|
|
14222
|
+
const namespacesRoot = path2.join(memoryRoot, "namespaces");
|
|
16030
14223
|
const fallbackStorageDir = typeof fallbackStorage.dir === "string" && fallbackStorage.dir ? fallbackStorage.dir : null;
|
|
16031
14224
|
const matches = [];
|
|
16032
14225
|
const seenDirs = /* @__PURE__ */ new Set();
|
|
16033
14226
|
const maybeAddStorage = (storage, namespace) => {
|
|
16034
14227
|
const storageDir = typeof storage.dir === "string" && storage.dir ? storage.dir : null;
|
|
16035
14228
|
if (!storageDir) return;
|
|
16036
|
-
const candidateRoot =
|
|
14229
|
+
const candidateRoot = path2.resolve(storageDir);
|
|
16037
14230
|
if (seenDirs.has(candidateRoot)) return;
|
|
16038
14231
|
if (!isPathInsideStorageRoot(candidateRoot, resolvedPath)) return;
|
|
16039
14232
|
if (candidateRoot === memoryRoot && isPathInsideStorageRoot(namespacesRoot, resolvedPath)) {
|
|
@@ -16051,7 +14244,7 @@ ${lines.join("\n\n")}`;
|
|
|
16051
14244
|
candidateNamespaces.add(ns);
|
|
16052
14245
|
}
|
|
16053
14246
|
if (isPathInsideStorageRoot(namespacesRoot, resolvedPath)) {
|
|
16054
|
-
const relativeToNamespaces =
|
|
14247
|
+
const relativeToNamespaces = path2.relative(namespacesRoot, resolvedPath);
|
|
16055
14248
|
const [namespaceSegment] = relativeToNamespaces.split(/[\\/]/);
|
|
16056
14249
|
if (namespaceSegment) {
|
|
16057
14250
|
candidateNamespaces.add(
|
|
@@ -16096,7 +14289,7 @@ ${lines.join("\n\n")}`;
|
|
|
16096
14289
|
nsMap = buildMemoryWorthCounterMap(memories);
|
|
16097
14290
|
this.memoryWorthCounterCache.set(ns, { at: nowMs, counters: nsMap });
|
|
16098
14291
|
}
|
|
16099
|
-
for (const [
|
|
14292
|
+
for (const [path3, c] of nsMap) counters.set(path3, c);
|
|
16100
14293
|
} catch (err) {
|
|
16101
14294
|
log.debug("memory-worth: failed to read namespace, skipping", {
|
|
16102
14295
|
namespace: ns,
|
|
@@ -16267,12 +14460,12 @@ ${lines.join("\n\n")}`;
|
|
|
16267
14460
|
*/
|
|
16268
14461
|
semanticDedupScopeFor(targetStorage) {
|
|
16269
14462
|
if (!this.config.namespacesEnabled) return {};
|
|
16270
|
-
const memoryDir =
|
|
16271
|
-
const storageDir =
|
|
14463
|
+
const memoryDir = path2.resolve(this.config.memoryDir);
|
|
14464
|
+
const storageDir = path2.resolve(targetStorage.dir);
|
|
16272
14465
|
if (storageDir === memoryDir) {
|
|
16273
14466
|
return { pathExcludePrefixes: ["namespaces/"] };
|
|
16274
14467
|
}
|
|
16275
|
-
let rel =
|
|
14468
|
+
let rel = path2.relative(memoryDir, storageDir);
|
|
16276
14469
|
if (!rel || rel.startsWith("..")) {
|
|
16277
14470
|
log.debug(
|
|
16278
14471
|
`semantic dedup: target storage dir ${storageDir} is outside memoryDir ${memoryDir}; scoping lookup to absolute path prefix`
|
|
@@ -16291,7 +14484,7 @@ ${lines.join("\n\n")}`;
|
|
|
16291
14484
|
if (hits.length === 0) return [];
|
|
16292
14485
|
const results = [];
|
|
16293
14486
|
for (const hit of hits) {
|
|
16294
|
-
const fullPath =
|
|
14487
|
+
const fullPath = path2.isAbsolute(hit.path) ? hit.path : path2.join(this.config.memoryDir, hit.path);
|
|
16295
14488
|
const memory = await this.storage.readMemoryByPath(fullPath);
|
|
16296
14489
|
if (!memory) continue;
|
|
16297
14490
|
results.push({
|
|
@@ -16495,7 +14688,7 @@ ${lines.join("\n\n")}`;
|
|
|
16495
14688
|
const storage = await this.storageRouter.storageFor(namespace);
|
|
16496
14689
|
const storageDir = typeof storage.dir === "string" && storage.dir ? storage.dir : null;
|
|
16497
14690
|
if (!storageDir) continue;
|
|
16498
|
-
const recallRoot =
|
|
14691
|
+
const recallRoot = path2.resolve(storageDir);
|
|
16499
14692
|
if (seenRecallRoots.has(recallRoot)) continue;
|
|
16500
14693
|
seenRecallRoots.add(recallRoot);
|
|
16501
14694
|
recallRoots.push(recallRoot);
|
|
@@ -16519,8 +14712,8 @@ ${lines.join("\n\n")}`;
|
|
|
16519
14712
|
if (resolvedCold) scopedResults.push(resolvedCold.result);
|
|
16520
14713
|
continue;
|
|
16521
14714
|
}
|
|
16522
|
-
if (
|
|
16523
|
-
const resolvedPath =
|
|
14715
|
+
if (path2.isAbsolute(result.path)) {
|
|
14716
|
+
const resolvedPath = path2.resolve(result.path);
|
|
16524
14717
|
if (recallRoots.some(
|
|
16525
14718
|
(recallRoot) => isPathInsideStorageRoot(recallRoot, resolvedPath)
|
|
16526
14719
|
)) {
|
|
@@ -17262,8 +15455,8 @@ ${lines.join("\n\n")}`;
|
|
|
17262
15455
|
}
|
|
17263
15456
|
namespaceFromStorageDir(storageDir) {
|
|
17264
15457
|
if (!this.config.namespacesEnabled) return this.config.defaultNamespace;
|
|
17265
|
-
const resolvedStorageDir =
|
|
17266
|
-
const resolvedMemoryDir =
|
|
15458
|
+
const resolvedStorageDir = path2.resolve(storageDir);
|
|
15459
|
+
const resolvedMemoryDir = path2.resolve(this.config.memoryDir);
|
|
17267
15460
|
if (resolvedStorageDir === resolvedMemoryDir)
|
|
17268
15461
|
return this.config.defaultNamespace;
|
|
17269
15462
|
const m = resolvedStorageDir.match(/[\\/]namespaces[\\/]([^\\/]+)$/);
|
|
@@ -17343,7 +15536,6 @@ export {
|
|
|
17343
15536
|
ensureBuiltInWearableConnectors,
|
|
17344
15537
|
WearablesService,
|
|
17345
15538
|
locateTranscriptPath,
|
|
17346
|
-
NamespaceCatalog,
|
|
17347
15539
|
BulkImportBatchPartialFailureError,
|
|
17348
15540
|
dedupeEntitySynthesisEvidenceEntries,
|
|
17349
15541
|
defaultWorkspaceDir,
|
|
@@ -17374,4 +15566,4 @@ export {
|
|
|
17374
15566
|
resolvePersistedMemoryRelativePath,
|
|
17375
15567
|
Orchestrator
|
|
17376
15568
|
};
|
|
17377
|
-
//# sourceMappingURL=chunk-
|
|
15569
|
+
//# sourceMappingURL=chunk-CE6CBRCV.js.map
|