@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.
- package/LICENSE +21 -21
- package/README.md +585 -585
- package/STATUS.md +294 -287
- package/cordis.patch.yml +69 -69
- package/package.json +1 -1
- package/scripts/check-package.mjs +76 -76
- package/scripts/inspect-log.ts +40 -0
- package/scripts/repair-ignorable.ts +130 -0
- package/scripts/scan-sessions.ts +43 -0
- package/scripts/verify-log.ts +26 -0
- package/src/SYSTEM.template.md +311 -311
- package/src/capture.js +242 -242
- package/src/client/index.js +886 -886
- package/src/config.js +57 -57
- package/src/consolidate-run.js +132 -132
- package/src/consolidate.js +330 -330
- package/src/context.js +88 -88
- package/src/decisions.js +106 -106
- package/src/defaults.js +93 -93
- package/src/digest.js +255 -255
- package/src/entry.js +179 -179
- package/src/eval.js +72 -72
- package/src/files.js +60 -60
- package/src/fixture.js +56 -56
- package/src/forget.js +71 -71
- package/src/freshness.js +65 -65
- package/src/fts.js +166 -166
- package/src/git.js +53 -53
- package/src/history.js +226 -226
- package/src/host.js +884 -866
- package/src/index.js +115 -94
- package/src/inject.js +108 -108
- package/src/layers.js +178 -178
- package/src/ledger.js +19 -19
- package/src/map.js +184 -184
- package/src/models.js +136 -136
- package/src/queue.js +602 -602
- package/src/rank.js +31 -31
- package/src/recover.js +91 -91
- package/src/refresh.js +80 -80
- package/src/remember.js +58 -58
- package/src/rerank.js +90 -90
- package/src/search.js +75 -75
- package/src/select.js +57 -57
- package/src/session.js +60 -60
- package/src/state.js +240 -240
- package/src/store.js +128 -128
- package/src/summarize.js +195 -195
- package/src/system-doc.js +171 -171
- package/src/tasks.js +617 -617
- package/src/telemetry.js +122 -122
- package/src/tools.js +828 -818
- package/src/working.js +288 -288
- package/src/workspace-key.js +68 -68
- 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
|
+
}
|