@try-works/dsh-memory 0.1.19 → 0.1.21

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.
Files changed (55) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +585 -585
  3. package/STATUS.md +294 -287
  4. package/cordis.patch.yml +69 -69
  5. package/package.json +1 -1
  6. package/scripts/check-package.mjs +76 -76
  7. package/scripts/inspect-log.ts +40 -0
  8. package/scripts/repair-ignorable.ts +130 -0
  9. package/scripts/scan-sessions.ts +43 -0
  10. package/scripts/verify-log.ts +26 -0
  11. package/src/SYSTEM.template.md +311 -311
  12. package/src/capture.js +242 -242
  13. package/src/client/index.js +886 -886
  14. package/src/config.js +57 -57
  15. package/src/consolidate-run.js +132 -132
  16. package/src/consolidate.js +330 -330
  17. package/src/context.js +88 -88
  18. package/src/decisions.js +106 -106
  19. package/src/defaults.js +93 -93
  20. package/src/digest.js +255 -255
  21. package/src/entry.js +179 -179
  22. package/src/eval.js +72 -72
  23. package/src/files.js +60 -60
  24. package/src/fixture.js +56 -56
  25. package/src/forget.js +71 -71
  26. package/src/freshness.js +65 -65
  27. package/src/fts.js +166 -166
  28. package/src/git.js +53 -53
  29. package/src/history.js +226 -226
  30. package/src/host.js +884 -866
  31. package/src/index.js +115 -94
  32. package/src/inject.js +108 -108
  33. package/src/layers.js +178 -178
  34. package/src/ledger.js +19 -19
  35. package/src/map.js +184 -184
  36. package/src/models.js +136 -136
  37. package/src/queue.js +602 -602
  38. package/src/rank.js +31 -31
  39. package/src/recover.js +91 -91
  40. package/src/refresh.js +80 -80
  41. package/src/remember.js +58 -58
  42. package/src/rerank.js +90 -90
  43. package/src/search.js +75 -75
  44. package/src/select.js +57 -57
  45. package/src/session.js +60 -60
  46. package/src/state.js +240 -240
  47. package/src/store.js +128 -128
  48. package/src/summarize.js +195 -195
  49. package/src/system-doc.js +171 -171
  50. package/src/tasks.js +617 -617
  51. package/src/telemetry.js +122 -122
  52. package/src/tools.js +828 -818
  53. package/src/working.js +288 -288
  54. package/src/workspace-key.js +68 -68
  55. package/src/workspace.js +87 -87
package/src/fixture.js CHANGED
@@ -1,56 +1,56 @@
1
- // Behavior-fixture regression suite: a single entry point that runs the critical
2
- // behaviors (ranking, freshness, forgetting, budget, workspace-key) under a fixed
3
- // clock and reports any regression by name.
4
- import { remember } from './remember.js'
5
- import { search } from './search.js'
6
- import { freshnessVerdict } from './freshness.js'
7
- import { forget } from './forget.js'
8
- import { allocateContextLanes } from './context.js'
9
- import { deriveWorkspaceKey } from './workspace-key.js'
10
-
11
- const DAY = 86400000
12
-
13
- export function runBehaviorFixtures(store, now = Date.now()) {
14
- const results = []
15
- const check = (id, axis, ok, detail) => results.push({ id, axis, ok, detail })
16
-
17
- // ranking: importance-3 outranks importance-1 for the same term
18
- remember(store, { scope: 'global', layer: 'semantic', text: 'critical zebra fact', importance: 3, topic: 'Critical' })
19
- remember(store, { scope: 'global', layer: 'semantic', text: 'routine zebra fact', importance: 1, topic: 'Routine' })
20
- const hits = search(store, 'zebra', { scope: 'global', limit: 5 })
21
- const crit = hits.findIndex((h) => h.id === 'global-sem-0001')
22
- const routine = hits.findIndex((h) => h.id === 'global-sem-0002')
23
- check('rank-01', 'ranking', crit >= 0 && (routine < 0 || crit < routine), 'importance-3 before importance-1')
24
-
25
- // freshness: the entry carries its own decay rate, and Verified is the age basis
26
- const future = now + 40 * DAY
27
- const stale = freshnessVerdict({ status: 'active', updated: new Date(now - 40 * DAY).toISOString(), content: 'durable' }, future)
28
- check('fresh-01', 'freshness', stale.verdict === 'stale', 'stale after 40 days at normal volatility')
29
- const stable = freshnessVerdict({ status: 'active', volatility: 'stable', updated: new Date(now - 40 * DAY).toISOString() }, future)
30
- check('fresh-02', 'freshness', stable.verdict === 'fresh', 'stable content is not stale at 40 days')
31
- const volatile = freshnessVerdict({ status: 'active', volatility: 'volatile', updated: new Date(now - 10 * DAY).toISOString() }, now)
32
- check('fresh-03', 'freshness', volatile.verdict === 'stale', 'volatile content is stale at 10 days')
33
- const reconfirmed = freshnessVerdict({ status: 'active', date: new Date(now - 400 * DAY).toISOString(), verified: new Date(now - 1 * DAY).toISOString() }, now)
34
- check('fresh-04', 'freshness', reconfirmed.verdict === 'fresh', 'Verified beats an old Date')
35
- const inferred = freshnessVerdict({ status: 'active', updated: new Date(now - 10 * DAY).toISOString(), content: 'currently pending' }, now)
36
- check('fresh-05', 'freshness', inferred.verdict === 'stale', 'time-bounded language still hints volatile')
37
-
38
- // forgetting: forget blocks re-remember of the same text
39
- const r = remember(store, { scope: 'global', layer: 'semantic', text: 'forget me please', topic: 'Forget' })
40
- forget(store, r.id)
41
- let blocked = false
42
- try { remember(store, { scope: 'global', layer: 'semantic', text: 'forget me please' }) } catch { blocked = true }
43
- check('forget-01', 'forgetting', blocked, 're-remember blocked after forget')
44
-
45
- // budget: working-memory floor preserved under a tiny budget
46
- const alloc = allocateContextLanes({ budgetTokens: 100, demand: { working: 500, episodes: 500, learnings: 500, queryRecall: 500 } })
47
- check('budget-01', 'budget', alloc.allocated.working === 100, 'working floor under constraint')
48
-
49
- // workspace-key: remote-derived key + no traversal
50
- check('key-01', 'workspace-key', deriveWorkspaceKey({ remote: 'git@github.com:owner/repo.git' }) === 'owner-repo', 'remote-derived key')
51
- const hostile = deriveWorkspaceKey({ basename: '../etc' })
52
- check('key-02', 'workspace-key', hostile === undefined || (!hostile.includes('..') && !hostile.includes('/')), 'no traversal from hostile basename')
53
-
54
- const failed = results.filter((r) => !r.ok)
55
- return { total: results.length, passed: results.length - failed.length, failed, results }
56
- }
1
+ // Behavior-fixture regression suite: a single entry point that runs the critical
2
+ // behaviors (ranking, freshness, forgetting, budget, workspace-key) under a fixed
3
+ // clock and reports any regression by name.
4
+ import { remember } from './remember.js'
5
+ import { search } from './search.js'
6
+ import { freshnessVerdict } from './freshness.js'
7
+ import { forget } from './forget.js'
8
+ import { allocateContextLanes } from './context.js'
9
+ import { deriveWorkspaceKey } from './workspace-key.js'
10
+
11
+ const DAY = 86400000
12
+
13
+ export function runBehaviorFixtures(store, now = Date.now()) {
14
+ const results = []
15
+ const check = (id, axis, ok, detail) => results.push({ id, axis, ok, detail })
16
+
17
+ // ranking: importance-3 outranks importance-1 for the same term
18
+ remember(store, { scope: 'global', layer: 'semantic', text: 'critical zebra fact', importance: 3, topic: 'Critical' })
19
+ remember(store, { scope: 'global', layer: 'semantic', text: 'routine zebra fact', importance: 1, topic: 'Routine' })
20
+ const hits = search(store, 'zebra', { scope: 'global', limit: 5 })
21
+ const crit = hits.findIndex((h) => h.id === 'global-sem-0001')
22
+ const routine = hits.findIndex((h) => h.id === 'global-sem-0002')
23
+ check('rank-01', 'ranking', crit >= 0 && (routine < 0 || crit < routine), 'importance-3 before importance-1')
24
+
25
+ // freshness: the entry carries its own decay rate, and Verified is the age basis
26
+ const future = now + 40 * DAY
27
+ const stale = freshnessVerdict({ status: 'active', updated: new Date(now - 40 * DAY).toISOString(), content: 'durable' }, future)
28
+ check('fresh-01', 'freshness', stale.verdict === 'stale', 'stale after 40 days at normal volatility')
29
+ const stable = freshnessVerdict({ status: 'active', volatility: 'stable', updated: new Date(now - 40 * DAY).toISOString() }, future)
30
+ check('fresh-02', 'freshness', stable.verdict === 'fresh', 'stable content is not stale at 40 days')
31
+ const volatile = freshnessVerdict({ status: 'active', volatility: 'volatile', updated: new Date(now - 10 * DAY).toISOString() }, now)
32
+ check('fresh-03', 'freshness', volatile.verdict === 'stale', 'volatile content is stale at 10 days')
33
+ const reconfirmed = freshnessVerdict({ status: 'active', date: new Date(now - 400 * DAY).toISOString(), verified: new Date(now - 1 * DAY).toISOString() }, now)
34
+ check('fresh-04', 'freshness', reconfirmed.verdict === 'fresh', 'Verified beats an old Date')
35
+ const inferred = freshnessVerdict({ status: 'active', updated: new Date(now - 10 * DAY).toISOString(), content: 'currently pending' }, now)
36
+ check('fresh-05', 'freshness', inferred.verdict === 'stale', 'time-bounded language still hints volatile')
37
+
38
+ // forgetting: forget blocks re-remember of the same text
39
+ const r = remember(store, { scope: 'global', layer: 'semantic', text: 'forget me please', topic: 'Forget' })
40
+ forget(store, r.id)
41
+ let blocked = false
42
+ try { remember(store, { scope: 'global', layer: 'semantic', text: 'forget me please' }) } catch { blocked = true }
43
+ check('forget-01', 'forgetting', blocked, 're-remember blocked after forget')
44
+
45
+ // budget: working-memory floor preserved under a tiny budget
46
+ const alloc = allocateContextLanes({ budgetTokens: 100, demand: { working: 500, episodes: 500, learnings: 500, queryRecall: 500 } })
47
+ check('budget-01', 'budget', alloc.allocated.working === 100, 'working floor under constraint')
48
+
49
+ // workspace-key: remote-derived key + no traversal
50
+ check('key-01', 'workspace-key', deriveWorkspaceKey({ remote: 'git@github.com:owner/repo.git' }) === 'owner-repo', 'remote-derived key')
51
+ const hostile = deriveWorkspaceKey({ basename: '../etc' })
52
+ check('key-02', 'workspace-key', hostile === undefined || (!hostile.includes('..') && !hostile.includes('/')), 'no traversal from hostile basename')
53
+
54
+ const failed = results.filter((r) => !r.ok)
55
+ return { total: results.length, passed: results.length - failed.length, failed, results }
56
+ }
package/src/forget.js CHANGED
@@ -1,71 +1,71 @@
1
- // Forget tombstones: hash-only, scope-scoped, exact-normalized-match suppression.
2
- // Enforced at the write site (remember) so a forgotten fact is never re-added silently.
3
- import { createHash } from 'node:crypto'
4
- import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
- import { join } from 'node:path'
6
- import { buildLedger } from './ledger.js'
7
- import { setEntryStatus } from './entry.js'
8
- import { updateIndex } from './fts.js'
9
- import { layerIndexPath, rebuildLayerIndexes, scopeOfFile } from './layers.js'
10
- import { layersForScope } from './entry.js'
11
-
12
- /** trim -> whitespace-collapse -> NFKC -> lowercase (exact match only, never paraphrase). */
13
- export function normalizeForSuppression(text) {
14
- return String(text ?? '').trim().replace(/\s+/g, ' ').normalize('NFKC').toLowerCase()
15
- }
16
-
17
- export function suppressionHash(scope, text) {
18
- return createHash('sha256').update((scope ?? 'global') + '\0' + normalizeForSuppression(text)).digest('hex')
19
- }
20
-
21
- export function tombstonesPath(root) { return join(root, 'state', 'tombstones.jsonl') }
22
-
23
- export function readTombstones(root) {
24
- try {
25
- return readFileSync(tombstonesPath(root), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l))
26
- } catch { return [] }
27
- }
28
-
29
- function appendTombstone(root, rec) {
30
- mkdirSync(join(root, 'state'), { recursive: true })
31
- appendFileSync(tombstonesPath(root), JSON.stringify(rec) + '\n')
32
- }
33
-
34
- export function isSuppressed(root, scope, text) {
35
- const hash = suppressionHash(scope, text)
36
- return readTombstones(root).some((t) => t.hash === hash)
37
- }
38
-
39
- /** Supersede an entry in place and record a tombstone so it can never be re-added. */
40
- export function forget(store, id) {
41
- const entry = buildLedger(store.root).find((e) => e.id === id)
42
- if (!entry) throw new Error('no such entry: ' + id)
43
- const scope = entry.file.startsWith('global/') ? 'global' : entry.file.split('/')[1]
44
- const hash = suppressionHash(scope, entry.content)
45
- appendTombstone(store.root, { hash, scope, id, createdAt: new Date().toISOString(), reason: 'user-forget' })
46
- const file = join(store.root, entry.file)
47
- writeFileSync(file, setEntryStatus(readFileSync(file, 'utf8'), id, 'superseded', 'user-forget'), 'utf8')
48
- // keep the layer index in step with the topic file it points at
49
- const where = scopeOfFile(entry.file)
50
- if (where) rebuildLayerIndexes(store.root, where.scope, where.key)
51
- updateIndex(store.root, [
52
- file,
53
- ...(where ? layersForScope(where.scope).map((l) => layerIndexPath(store.root, where.scope, where.key, l)) : []),
54
- ])
55
- return { id, hash, scope }
56
- }
57
-
58
- /** Release a forgotten entry's tombstone (explicit action only). Entry stays superseded. */
59
- export function forgetUndo(store, id) {
60
- const list = readTombstones(store.root)
61
- const remaining = list.filter((t) => t.id !== id)
62
- if (remaining.length === list.length) return false
63
- mkdirSync(join(store.root, 'state'), { recursive: true })
64
- writeFileSync(tombstonesPath(store.root), remaining.map((t) => JSON.stringify(t)).join('\n') + (remaining.length ? '\n' : ''), 'utf8')
65
- return true
66
- }
67
-
68
- /** Hash-prefixed tombstone listing (no plaintext). */
69
- export function forgetList(store) {
70
- return readTombstones(store.root).map((t) => ({ id: t.id, scope: t.scope, createdAt: t.createdAt, reason: t.reason, hashPrefix: t.hash.slice(0, 12) }))
71
- }
1
+ // Forget tombstones: hash-only, scope-scoped, exact-normalized-match suppression.
2
+ // Enforced at the write site (remember) so a forgotten fact is never re-added silently.
3
+ import { createHash } from 'node:crypto'
4
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+ import { buildLedger } from './ledger.js'
7
+ import { setEntryStatus } from './entry.js'
8
+ import { updateIndex } from './fts.js'
9
+ import { layerIndexPath, rebuildLayerIndexes, scopeOfFile } from './layers.js'
10
+ import { layersForScope } from './entry.js'
11
+
12
+ /** trim -> whitespace-collapse -> NFKC -> lowercase (exact match only, never paraphrase). */
13
+ export function normalizeForSuppression(text) {
14
+ return String(text ?? '').trim().replace(/\s+/g, ' ').normalize('NFKC').toLowerCase()
15
+ }
16
+
17
+ export function suppressionHash(scope, text) {
18
+ return createHash('sha256').update((scope ?? 'global') + '\0' + normalizeForSuppression(text)).digest('hex')
19
+ }
20
+
21
+ export function tombstonesPath(root) { return join(root, 'state', 'tombstones.jsonl') }
22
+
23
+ export function readTombstones(root) {
24
+ try {
25
+ return readFileSync(tombstonesPath(root), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l))
26
+ } catch { return [] }
27
+ }
28
+
29
+ function appendTombstone(root, rec) {
30
+ mkdirSync(join(root, 'state'), { recursive: true })
31
+ appendFileSync(tombstonesPath(root), JSON.stringify(rec) + '\n')
32
+ }
33
+
34
+ export function isSuppressed(root, scope, text) {
35
+ const hash = suppressionHash(scope, text)
36
+ return readTombstones(root).some((t) => t.hash === hash)
37
+ }
38
+
39
+ /** Supersede an entry in place and record a tombstone so it can never be re-added. */
40
+ export function forget(store, id) {
41
+ const entry = buildLedger(store.root).find((e) => e.id === id)
42
+ if (!entry) throw new Error('no such entry: ' + id)
43
+ const scope = entry.file.startsWith('global/') ? 'global' : entry.file.split('/')[1]
44
+ const hash = suppressionHash(scope, entry.content)
45
+ appendTombstone(store.root, { hash, scope, id, createdAt: new Date().toISOString(), reason: 'user-forget' })
46
+ const file = join(store.root, entry.file)
47
+ writeFileSync(file, setEntryStatus(readFileSync(file, 'utf8'), id, 'superseded', 'user-forget'), 'utf8')
48
+ // keep the layer index in step with the topic file it points at
49
+ const where = scopeOfFile(entry.file)
50
+ if (where) rebuildLayerIndexes(store.root, where.scope, where.key)
51
+ updateIndex(store.root, [
52
+ file,
53
+ ...(where ? layersForScope(where.scope).map((l) => layerIndexPath(store.root, where.scope, where.key, l)) : []),
54
+ ])
55
+ return { id, hash, scope }
56
+ }
57
+
58
+ /** Release a forgotten entry's tombstone (explicit action only). Entry stays superseded. */
59
+ export function forgetUndo(store, id) {
60
+ const list = readTombstones(store.root)
61
+ const remaining = list.filter((t) => t.id !== id)
62
+ if (remaining.length === list.length) return false
63
+ mkdirSync(join(store.root, 'state'), { recursive: true })
64
+ writeFileSync(tombstonesPath(store.root), remaining.map((t) => JSON.stringify(t)).join('\n') + (remaining.length ? '\n' : ''), 'utf8')
65
+ return true
66
+ }
67
+
68
+ /** Hash-prefixed tombstone listing (no plaintext). */
69
+ export function forgetList(store) {
70
+ return readTombstones(store.root).map((t) => ({ id: t.id, scope: t.scope, createdAt: t.createdAt, reason: t.reason, hashPrefix: t.hash.slice(0, 12) }))
71
+ }
package/src/freshness.js CHANGED
@@ -1,65 +1,65 @@
1
- // Deterministic freshness verdicts over the derived entry ledger. Pure.
2
- //
3
- // Freshness is decided from metadata the ENTRY carries - its declared volatility and when it
4
- // was last verified - rather than from one global constant applied to a write date. A language
5
- // choice and a port number do not decay at the same rate, and only the writer knows which is
6
- // which. The old global threshold treated every fact as equally perishable.
7
- import { normalizeVolatility, VOLATILITY_DAYS } from './entry.js'
8
-
9
- /** Fallback window for entries written before volatility was recorded. */
10
- export const STALE_DAYS = VOLATILITY_DAYS.normal
11
- /** Volatile entries are reviewed after this many days even when not yet due. */
12
- export const VOLATILE_DAYS = VOLATILITY_DAYS.volatile
13
- const DAY_MS = 86400000
14
-
15
- /** Language that marks a statement as time-bound, regardless of declared volatility. */
16
- const TIME_BOUNDED_RE = /\b(currently|as of|as-of|pending|soon|today|this week|planned|plan to|to do|todo|next week|upcoming|will|wip|tbd)\b/i
17
-
18
- /** Time-bounded language marks a fact as volatile (decays faster than durable facts). */
19
- export function isVolatile(text) { return TIME_BOUNDED_RE.test(String(text ?? '')) }
20
-
21
- /**
22
- * Days after which this entry is worth re-checking.
23
- *
24
- * Precedence: the entry's declared volatility, then (for entries written before the field
25
- * existed) time-bounded language, then the normal window.
26
- */
27
- export function staleAfterDays(entry = {}) {
28
- if (entry.volatility) return VOLATILITY_DAYS[normalizeVolatility(entry.volatility)]
29
- if (isVolatile(entry.content)) return VOLATILITY_DAYS.volatile
30
- return STALE_DAYS
31
- }
32
-
33
- /**
34
- * Verdict tiers: skip (non-active) > expired (Valid-until) > stale (past its own window) >
35
- * review (volatile and aging) > fresh.
36
- *
37
- * Age is measured from **Verified** when present - that is when the fact was last confirmed
38
- * true - and falls back to Updated/Date, which only say when it was written.
39
- */
40
- export function freshnessVerdict(entry, nowMs = Date.now()) {
41
- if (entry.status && entry.status !== 'active') return { verdict: 'skip' }
42
- if (entry.until && Date.parse(entry.until) <= nowMs) return { verdict: 'expired' }
43
- const base = entry.verified ?? entry.updated ?? entry.date
44
- const updatedMs = base ? Date.parse(base) : nowMs
45
- const days = Math.max(0, (nowMs - updatedMs) / DAY_MS)
46
- const window = staleAfterDays(entry)
47
- if (days > window) return { verdict: 'stale', days: Math.round(days), window }
48
- const volatility = entry.volatility ?? (isVolatile(entry.content) ? 'volatile' : 'normal')
49
- if (volatility === 'volatile' && days > VOLATILITY_DAYS.volatile) {
50
- return { verdict: 'review', days: Math.round(days), window }
51
- }
52
- return { verdict: 'fresh', days: Math.round(days), window }
53
- }
54
-
55
- /** Return only entries that need attention (expired/stale/review), annotated with their verdict. */
56
- export function scanFreshness(ledger, nowMs = Date.now()) {
57
- return ledger
58
- .map((e) => ({ ...e, ...freshnessVerdict(e, nowMs) }))
59
- .filter((e) => e.verdict !== 'fresh' && e.verdict !== 'skip')
60
- }
61
-
62
- /** Confirm an entry is still true now: returns the metadata patch the writer should apply. */
63
- export function verifiedPatch(nowIso = new Date().toISOString()) {
64
- return { verified: nowIso, updated: nowIso }
65
- }
1
+ // Deterministic freshness verdicts over the derived entry ledger. Pure.
2
+ //
3
+ // Freshness is decided from metadata the ENTRY carries - its declared volatility and when it
4
+ // was last verified - rather than from one global constant applied to a write date. A language
5
+ // choice and a port number do not decay at the same rate, and only the writer knows which is
6
+ // which. The old global threshold treated every fact as equally perishable.
7
+ import { normalizeVolatility, VOLATILITY_DAYS } from './entry.js'
8
+
9
+ /** Fallback window for entries written before volatility was recorded. */
10
+ export const STALE_DAYS = VOLATILITY_DAYS.normal
11
+ /** Volatile entries are reviewed after this many days even when not yet due. */
12
+ export const VOLATILE_DAYS = VOLATILITY_DAYS.volatile
13
+ const DAY_MS = 86400000
14
+
15
+ /** Language that marks a statement as time-bound, regardless of declared volatility. */
16
+ const TIME_BOUNDED_RE = /\b(currently|as of|as-of|pending|soon|today|this week|planned|plan to|to do|todo|next week|upcoming|will|wip|tbd)\b/i
17
+
18
+ /** Time-bounded language marks a fact as volatile (decays faster than durable facts). */
19
+ export function isVolatile(text) { return TIME_BOUNDED_RE.test(String(text ?? '')) }
20
+
21
+ /**
22
+ * Days after which this entry is worth re-checking.
23
+ *
24
+ * Precedence: the entry's declared volatility, then (for entries written before the field
25
+ * existed) time-bounded language, then the normal window.
26
+ */
27
+ export function staleAfterDays(entry = {}) {
28
+ if (entry.volatility) return VOLATILITY_DAYS[normalizeVolatility(entry.volatility)]
29
+ if (isVolatile(entry.content)) return VOLATILITY_DAYS.volatile
30
+ return STALE_DAYS
31
+ }
32
+
33
+ /**
34
+ * Verdict tiers: skip (non-active) > expired (Valid-until) > stale (past its own window) >
35
+ * review (volatile and aging) > fresh.
36
+ *
37
+ * Age is measured from **Verified** when present - that is when the fact was last confirmed
38
+ * true - and falls back to Updated/Date, which only say when it was written.
39
+ */
40
+ export function freshnessVerdict(entry, nowMs = Date.now()) {
41
+ if (entry.status && entry.status !== 'active') return { verdict: 'skip' }
42
+ if (entry.until && Date.parse(entry.until) <= nowMs) return { verdict: 'expired' }
43
+ const base = entry.verified ?? entry.updated ?? entry.date
44
+ const updatedMs = base ? Date.parse(base) : nowMs
45
+ const days = Math.max(0, (nowMs - updatedMs) / DAY_MS)
46
+ const window = staleAfterDays(entry)
47
+ if (days > window) return { verdict: 'stale', days: Math.round(days), window }
48
+ const volatility = entry.volatility ?? (isVolatile(entry.content) ? 'volatile' : 'normal')
49
+ if (volatility === 'volatile' && days > VOLATILITY_DAYS.volatile) {
50
+ return { verdict: 'review', days: Math.round(days), window }
51
+ }
52
+ return { verdict: 'fresh', days: Math.round(days), window }
53
+ }
54
+
55
+ /** Return only entries that need attention (expired/stale/review), annotated with their verdict. */
56
+ export function scanFreshness(ledger, nowMs = Date.now()) {
57
+ return ledger
58
+ .map((e) => ({ ...e, ...freshnessVerdict(e, nowMs) }))
59
+ .filter((e) => e.verdict !== 'fresh' && e.verdict !== 'skip')
60
+ }
61
+
62
+ /** Confirm an entry is still true now: returns the metadata patch the writer should apply. */
63
+ export function verifiedPatch(nowIso = new Date().toISOString()) {
64
+ return { verified: nowIso, updated: nowIso }
65
+ }