sanook-cli 0.4.0 → 0.5.1
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/.env.example +19 -0
- package/CHANGELOG.md +173 -0
- package/README.md +153 -20
- package/README.th.md +136 -0
- package/dist/agentContext.js +4 -0
- package/dist/approval.js +6 -0
- package/dist/bin.js +405 -57
- package/dist/brain.js +92 -59
- package/dist/brand.js +47 -0
- package/dist/checkpoint.js +37 -0
- package/dist/commands.js +86 -6
- package/dist/compaction.js +76 -5
- package/dist/config.js +100 -12
- package/dist/cost.js +60 -3
- package/dist/doctor.js +92 -0
- package/dist/gateway/auth.js +2 -2
- package/dist/gateway/ledger.js +2 -2
- package/dist/gateway/scheduler.js +1 -0
- package/dist/gateway/serve.js +6 -4
- package/dist/gateway/server.js +10 -2
- package/dist/git.js +11 -2
- package/dist/hooks.js +43 -17
- package/dist/knowledge.js +48 -49
- package/dist/loop.js +182 -66
- package/dist/lsp/client.js +173 -0
- package/dist/lsp/framing.js +56 -0
- package/dist/lsp/index.js +138 -0
- package/dist/lsp/servers.js +82 -0
- package/dist/mcp-server.js +244 -0
- package/dist/mcp.js +184 -29
- package/dist/memory-store.js +559 -0
- package/dist/memory.js +143 -29
- package/dist/orchestrate.js +150 -0
- package/dist/providers/codex.js +21 -7
- package/dist/providers/keys.js +3 -2
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +155 -1
- package/dist/repomap.js +93 -0
- package/dist/search/chunk.js +158 -0
- package/dist/search/embed-store.js +187 -0
- package/dist/search/engine.js +203 -0
- package/dist/search/fuse.js +35 -0
- package/dist/search/index-core.js +187 -0
- package/dist/search/indexer.js +241 -0
- package/dist/search/store.js +77 -0
- package/dist/session.js +42 -8
- package/dist/skill-install.js +10 -10
- package/dist/skills.js +12 -9
- package/dist/summarize.js +31 -0
- package/dist/tools/bash.js +21 -2
- package/dist/tools/diagnostics.js +41 -0
- package/dist/tools/edit.js +29 -7
- package/dist/tools/index.js +8 -1
- package/dist/tools/list.js +7 -2
- package/dist/tools/permission.js +90 -9
- package/dist/tools/read.js +23 -4
- package/dist/tools/remember.js +1 -1
- package/dist/tools/sandbox.js +61 -0
- package/dist/tools/search.js +105 -4
- package/dist/tools/task.js +195 -29
- package/dist/tools/timeout.js +35 -0
- package/dist/tools/util.js +10 -0
- package/dist/tools/write.js +6 -4
- package/dist/trust.js +89 -0
- package/dist/ui/app.js +228 -31
- package/dist/ui/banner.js +4 -9
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +30 -0
- package/dist/ui/mentions.js +44 -0
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +97 -12
- package/dist/ui/useEditor.js +83 -0
- package/dist/update.js +114 -0
- package/dist/worktree.js +173 -0
- package/package.json +11 -5
- package/scripts/postinstall.mjs +33 -0
- package/second-brain/.agents/_Index.md +30 -0
- package/second-brain/.agents/skills/_Index.md +30 -0
- package/second-brain/.agents/workflows/_Index.md +30 -0
- package/second-brain/AGENTS.md +4 -4
- package/second-brain/Acceptance/_Index.md +30 -0
- package/second-brain/Acceptance/golden-case-template.md +39 -0
- package/second-brain/Areas/_Index.md +30 -0
- package/second-brain/Bugs/System-OS/_Index.md +30 -0
- package/second-brain/Bugs/_Index.md +30 -0
- package/second-brain/CLAUDE.md +4 -1
- package/second-brain/Checklists/_Index.md +30 -0
- package/second-brain/Checklists/preflight-postflight-template.md +29 -0
- package/second-brain/Distillations/_Index.md +30 -0
- package/second-brain/Entities/_Index.md +30 -0
- package/second-brain/Entities/entity-template.md +33 -0
- package/second-brain/Evals/_Index.md +30 -0
- package/second-brain/Evals/correction-pairs.md +24 -0
- package/second-brain/Evals/failure-taxonomy.md +24 -0
- package/second-brain/Evals/golden-set.md +25 -0
- package/second-brain/Evals/quality-ledger.md +23 -0
- package/second-brain/Evals/self-eval-rubric.md +23 -0
- package/second-brain/GEMINI.md +4 -4
- package/second-brain/Goals/_Index.md +30 -0
- package/second-brain/Handoffs/_Index.md +30 -0
- package/second-brain/Home.md +7 -0
- package/second-brain/Intake/Raw Sources/_Index.md +30 -0
- package/second-brain/Intake/_Index.md +30 -0
- package/second-brain/Intake/_Quarantine/_Index.md +30 -0
- package/second-brain/Learning/_Index.md +30 -0
- package/second-brain/Playbooks/_Index.md +30 -0
- package/second-brain/Playbooks/playbook-template.md +23 -0
- package/second-brain/Projects/_Index.md +30 -0
- package/second-brain/Prompts/_Index.md +30 -0
- package/second-brain/README.md +2 -1
- package/second-brain/Research/_Index.md +30 -0
- package/second-brain/Retrospectives/_Index.md +30 -0
- package/second-brain/Reviews/_Index.md +30 -0
- package/second-brain/Runbooks/_Index.md +30 -0
- package/second-brain/Runbooks/eval-loop.md +24 -0
- package/second-brain/Sessions/_Index.md +30 -0
- package/second-brain/Shared/AI-Context-Index.md +20 -0
- package/second-brain/Shared/AI-Threads/_Index.md +30 -0
- package/second-brain/Shared/Archive/_Index.md +30 -0
- package/second-brain/Shared/Assets/_Index.md +30 -0
- package/second-brain/Shared/Context-Packs/_Index.md +30 -0
- package/second-brain/Shared/Context7-Docs/_Index.md +30 -0
- package/second-brain/Shared/Coordination/NOW.md +28 -0
- package/second-brain/Shared/Coordination/_Index.md +30 -0
- package/second-brain/Shared/Coordination/agent-registry.md +24 -0
- package/second-brain/Shared/Coordination/task-board/_Index.md +30 -0
- package/second-brain/Shared/Coordination/task-board/task-template.md +43 -0
- package/second-brain/Shared/Coordination/task-board.md +32 -0
- package/second-brain/Shared/Core-Facts/_Index.md +30 -0
- package/second-brain/Shared/Decision-Memory/_Index.md +30 -0
- package/second-brain/Shared/Glossary/_Index.md +30 -0
- package/second-brain/Shared/Memory-Inbox/_Index.md +30 -0
- package/second-brain/Shared/Operating-State/_Index.md +30 -0
- package/second-brain/Shared/Prompting/_Index.md +30 -0
- package/second-brain/Shared/Provenance/_Index.md +30 -0
- package/second-brain/Shared/Rules/_Index.md +30 -0
- package/second-brain/Shared/Rules/contextual-note-rule.md +30 -0
- package/second-brain/Shared/Rules/frontmatter-standard.md +10 -0
- package/second-brain/Shared/Rules/memory-write-protocol.md +28 -0
- package/second-brain/Shared/Rules/procedural-runbook-header.md +40 -0
- package/second-brain/Shared/Rules/review-and-staleness-policy.md +22 -0
- package/second-brain/Shared/Rules/rules-formatting.md +34 -0
- package/second-brain/Shared/Scripts/_Index.md +30 -0
- package/second-brain/Shared/Scripts-Archive/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +30 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +40 -0
- package/second-brain/Shared/User-Memory/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/_Index.md +30 -0
- package/second-brain/Shared/User-Persona/owner-profile.md +25 -0
- package/second-brain/Shared/Working-Memory/_Index.md +30 -0
- package/second-brain/Shared/_Index.md +30 -0
- package/second-brain/Shared/mcp-servers/_Index.md +30 -0
- package/second-brain/Skills/_Index.md +30 -0
- package/second-brain/Templates/_Index.md +30 -0
- package/second-brain/Templates/bug.md +2 -0
- package/second-brain/Templates/handoff.md +2 -0
- package/second-brain/Templates/session.md +2 -0
- package/second-brain/Tools/_Index.md +30 -0
- package/second-brain/Traces/_Index.md +30 -0
- package/second-brain/Vault Structure Map.md +33 -1
- package/second-brain/copilot/_Index.md +30 -0
- package/skills/audit-license-compliance/SKILL.md +117 -0
- package/skills/author-codemod/SKILL.md +110 -0
- package/skills/build-audit-logging/SKILL.md +112 -0
- package/skills/build-cdc-streaming-pipeline/SKILL.md +123 -0
- package/skills/build-cli-tool/SKILL.md +108 -0
- package/skills/build-data-table/SKILL.md +141 -0
- package/skills/build-native-mobile-ui/SKILL.md +154 -0
- package/skills/build-offline-first-sync/SKILL.md +118 -0
- package/skills/build-realtime-channel/SKILL.md +122 -0
- package/skills/build-vector-search/SKILL.md +131 -0
- package/skills/compose-local-dev-stack/SKILL.md +149 -0
- package/skills/configure-bundler-build/SKILL.md +166 -0
- package/skills/configure-dns-tls/SKILL.md +142 -0
- package/skills/configure-reverse-proxy-lb/SKILL.md +129 -0
- package/skills/configure-security-headers-csp/SKILL.md +122 -0
- package/skills/contract-testing/SKILL.md +140 -0
- package/skills/datetime-timezone-correctness/SKILL.md +125 -0
- package/skills/debug-ci-pipeline-failure/SKILL.md +134 -0
- package/skills/debug-flaky-tests/SKILL.md +128 -0
- package/skills/defend-llm-prompt-injection/SKILL.md +110 -0
- package/skills/deliver-webhooks/SKILL.md +116 -0
- package/skills/design-api-pagination/SKILL.md +144 -0
- package/skills/design-authorization-model/SKILL.md +119 -0
- package/skills/design-backup-dr-recovery/SKILL.md +113 -0
- package/skills/design-event-sourcing-cqrs/SKILL.md +143 -0
- package/skills/design-multi-tenancy/SKILL.md +100 -0
- package/skills/design-protobuf-grpc-service/SKILL.md +146 -0
- package/skills/design-relational-schema/SKILL.md +129 -0
- package/skills/design-search-index-infra/SKILL.md +151 -0
- package/skills/design-state-machine/SKILL.md +108 -0
- package/skills/design-token-system/SKILL.md +109 -0
- package/skills/distributed-locks-leases/SKILL.md +120 -0
- package/skills/encrypt-sensitive-data/SKILL.md +148 -0
- package/skills/feature-flags-rollout/SKILL.md +130 -0
- package/skills/file-upload-object-storage/SKILL.md +107 -0
- package/skills/fuzz-dynamic-security-test/SKILL.md +111 -0
- package/skills/harden-llm-app-reliability/SKILL.md +126 -0
- package/skills/i18n-localization-setup/SKILL.md +113 -0
- package/skills/idempotency-keys/SKILL.md +107 -0
- package/skills/implement-push-notifications/SKILL.md +142 -0
- package/skills/ingest-webhook-secure/SKILL.md +120 -0
- package/skills/integrate-oauth-oidc/SKILL.md +126 -0
- package/skills/load-stress-test/SKILL.md +129 -0
- package/skills/map-privacy-data-gdpr/SKILL.md +146 -0
- package/skills/model-nosql-data/SKILL.md +118 -0
- package/skills/money-decimal-arithmetic/SKILL.md +123 -0
- package/skills/monitor-ml-drift/SKILL.md +109 -0
- package/skills/numeric-precision-units/SKILL.md +144 -0
- package/skills/optimize-llm-cost-latency/SKILL.md +103 -0
- package/skills/optimize-react-rerenders/SKILL.md +124 -0
- package/skills/orchestrate-agent-workflow/SKILL.md +100 -0
- package/skills/payments-billing-integration/SKILL.md +114 -0
- package/skills/pin-toolchain-versions/SKILL.md +116 -0
- package/skills/plan-strangler-migration/SKILL.md +95 -0
- package/skills/property-based-testing/SKILL.md +108 -0
- package/skills/publish-package-registry/SKILL.md +130 -0
- package/skills/recover-git-state/SKILL.md +119 -0
- package/skills/remediate-web-vulnerabilities/SKILL.md +125 -0
- package/skills/resilience-timeouts-retries/SKILL.md +104 -0
- package/skills/resolve-merge-rebase-conflict/SKILL.md +97 -0
- package/skills/rewrite-git-history/SKILL.md +109 -0
- package/skills/scaffold-cross-platform-app/SKILL.md +137 -0
- package/skills/schema-evolution-compatibility/SKILL.md +121 -0
- package/skills/send-transactional-email/SKILL.md +126 -0
- package/skills/serve-deploy-ml-model/SKILL.md +107 -0
- package/skills/setup-cdn-edge-waf/SKILL.md +107 -0
- package/skills/setup-devcontainer-env/SKILL.md +131 -0
- package/skills/setup-lint-format-precommit/SKILL.md +140 -0
- package/skills/setup-monorepo-tooling/SKILL.md +125 -0
- package/skills/ship-mobile-app-store-release/SKILL.md +137 -0
- package/skills/structured-output-llm/SKILL.md +86 -0
- package/skills/supply-chain-sbom-provenance/SKILL.md +120 -0
- package/skills/test-data-factories/SKILL.md +158 -0
- package/skills/threat-model-stride/SKILL.md +123 -0
- package/skills/train-evaluate-ml-model/SKILL.md +109 -0
- package/skills/unicode-text-correctness/SKILL.md +109 -0
- package/skills/visual-regression-testing/SKILL.md +120 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// src/memory-store.ts — self-organizing auto-memory store (single source of
|
|
3
|
+
// truth for PATHS + the Fact FORMAT + all pure transform fns + the FS boundary).
|
|
4
|
+
//
|
|
5
|
+
// Why this module exists: the old auto-memory was a flat append-only list of
|
|
6
|
+
// "- <fact>" lines. The writer (memory.ts) and reader (knowledge.ts) each
|
|
7
|
+
// hardcoded the same path and assumed one fact per physical line, so they could
|
|
8
|
+
// silently drift, and there was no merge/importance/decay — duplicates piled up
|
|
9
|
+
// and stale facts became prompt noise.
|
|
10
|
+
//
|
|
11
|
+
// Now: `memory.json` (version 2) is the source of truth; `MEMORY.md` is a
|
|
12
|
+
// derived, ranked, human-readable view re-rendered on every write. Merge,
|
|
13
|
+
// decay, and consolidation are PURE Fact[]→Fact[] functions with an injected
|
|
14
|
+
// clock, so they unit-test with zero filesystem. This encodes the vault's own
|
|
15
|
+
// memory doctrine in code: "Merge, Don't Append" (Mem0-style ADD/UPDATE/NOOP/
|
|
16
|
+
// SUPERSEDE), bi-temporal soft-delete (history stays queryable), importance +
|
|
17
|
+
// recency decay with a real archive action, a protected tier, and an inbox TTL.
|
|
18
|
+
// No network, no embeddings — similarity is deterministic token Jaccard.
|
|
19
|
+
// ============================================================================
|
|
20
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { randomUUID } from 'node:crypto';
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
import { appHomePath, BRAND, persistenceEnabled } from './brand.js';
|
|
25
|
+
import { redactKey } from './providers/keys.js';
|
|
26
|
+
// ---- enums / taxonomy ------------------------------------------------------
|
|
27
|
+
export const TRUST = ['owner', 'agent', 'derived', 'untrusted'];
|
|
28
|
+
export const STATUS = ['active', 'superseded', 'archived'];
|
|
29
|
+
export const TIER = ['protected', 'durable', 'inbox'];
|
|
30
|
+
// fixed taxonomy; .catch('reference') means an invented type falls back instead of throwing
|
|
31
|
+
export const NOTE_TYPE = ['preference', 'decision', 'convention', 'fact', 'entity', 'skill', 'reference'];
|
|
32
|
+
// ---- schema ----------------------------------------------------------------
|
|
33
|
+
export const FactSchema = z
|
|
34
|
+
.object({
|
|
35
|
+
id: z.string(), // 'm_' + 6 chars of fnv1a(normalize(text)) — stable across runs/migrations
|
|
36
|
+
text: z.string().min(1), // atomic claim, already redactKey()'d
|
|
37
|
+
noteType: z.enum(NOTE_TYPE).catch('reference'),
|
|
38
|
+
tier: z.enum(TIER).default('durable'), // protected = owner ground-truth, never auto-superseded/archived
|
|
39
|
+
trust: z.enum(TRUST).default('agent'),
|
|
40
|
+
tags: z.array(z.string()).default([]),
|
|
41
|
+
importance: z.number().min(0).max(1).default(0.5),
|
|
42
|
+
accessCount: z.number().int().min(0).default(0),
|
|
43
|
+
status: z.enum(STATUS).default('active'), // bi-temporal partition
|
|
44
|
+
validFrom: z.number(), // when it started being true
|
|
45
|
+
invalidatedAt: z.number().nullable().default(null), // set on supersede/archive; null = still true
|
|
46
|
+
supersededBy: z.string().nullable().default(null),
|
|
47
|
+
supersedes: z.array(z.string()).default([]), // two-sided edge
|
|
48
|
+
related: z.array(z.string()).default([]),
|
|
49
|
+
parent: z.string().default('auto-memory'), // anti-orphan: every fact has an `up`
|
|
50
|
+
source: z.string().nullable().default(null),
|
|
51
|
+
created: z.number(),
|
|
52
|
+
updated: z.number(),
|
|
53
|
+
lastAccessed: z.number(),
|
|
54
|
+
reviewAfter: z.number().nullable().default(null), // staleness signal consolidation acts on
|
|
55
|
+
})
|
|
56
|
+
.strict();
|
|
57
|
+
export const MetaSchema = z
|
|
58
|
+
.object({
|
|
59
|
+
lastConsolidated: z.number().default(0),
|
|
60
|
+
activeAtLastConsolidate: z.number().int().default(0),
|
|
61
|
+
migratedFrom: z.string().nullable().default(null),
|
|
62
|
+
})
|
|
63
|
+
.strict();
|
|
64
|
+
export const StoreSchema = z
|
|
65
|
+
.object({ version: z.literal(2), meta: MetaSchema, facts: z.array(FactSchema) })
|
|
66
|
+
.strict();
|
|
67
|
+
// ---- tuning constants ------------------------------------------------------
|
|
68
|
+
const NEAR_DUP = 0.82; // sim ≥ this ⇒ same fact (NOOP/UPDATE)
|
|
69
|
+
const RELATED = 0.45; // RELATED ≤ sim < NEAR_DUP ⇒ related; supersede only on a clear contradiction
|
|
70
|
+
const HALF_LIFE_DAYS = 30; // importance halves every 30 days untouched
|
|
71
|
+
const ARCHIVE_FLOOR = 0.15; // effImportance below this (and untouched/stale) ⇒ archive
|
|
72
|
+
const DAY_MS = 86_400_000;
|
|
73
|
+
const INBOX_TTL_MS = 14 * DAY_MS; // inbox items must not linger > 2 weeks
|
|
74
|
+
const CONSOLIDATE_EVERY_MS = DAY_MS; // cadence: at most once a day …
|
|
75
|
+
const CONSOLIDATE_EVERY_N = 25; // … or after +25 new active facts
|
|
76
|
+
const PROMPT_CAP = 6000; // ~2k tokens — bounded retrieval (anti context-rot)
|
|
77
|
+
const PROMPT_NOTE = 'สิ่งที่จำไว้จาก session ก่อน'; // MUST match the legacy <auto_memory note="…"> text
|
|
78
|
+
// importance prior by note type
|
|
79
|
+
const PRIOR = {
|
|
80
|
+
decision: 0.7,
|
|
81
|
+
convention: 0.7,
|
|
82
|
+
preference: 0.6,
|
|
83
|
+
entity: 0.55,
|
|
84
|
+
skill: 0.6,
|
|
85
|
+
fact: 0.5,
|
|
86
|
+
reference: 0.5,
|
|
87
|
+
};
|
|
88
|
+
// negation / polarity-flip tokens (en + th) used for deterministic contradiction detection
|
|
89
|
+
const NEGATION = new Set([
|
|
90
|
+
'not', 'never', 'no', "n't", 'instead', 'stop', 'stopped', 'quit', 'avoid', 'drop', 'dropped',
|
|
91
|
+
'ไม่', 'เลิก', 'หยุด', 'แทน', 'งด',
|
|
92
|
+
]);
|
|
93
|
+
const STOPWORDS = new Set([
|
|
94
|
+
'the', 'a', 'an', 'to', 'of', 'is', 'are', 'was', 'be', 'in', 'on', 'at', 'for', 'with', 'and',
|
|
95
|
+
'or', 'use', 'uses', 'using', 'now', 'then', 'this', 'that', 'it', 'as', 'by', 'we', 'i',
|
|
96
|
+
'ใน', 'ที่', 'เป็น', 'และ', 'กับ', 'ของ', 'ให้', 'จะ', 'ได้',
|
|
97
|
+
]);
|
|
98
|
+
// ---- path constants (the SINGLE source — kills the memory.ts vs knowledge.ts drift) ----
|
|
99
|
+
export const MEMORY_DIR = appHomePath('memory');
|
|
100
|
+
export const MEMORY_JSON = join(MEMORY_DIR, 'memory.json'); // source of truth
|
|
101
|
+
export const AUTO_MEMORY_FILE = join(MEMORY_DIR, 'MEMORY.md'); // derived view (legacy name kept)
|
|
102
|
+
const MEMORY_BAK = join(MEMORY_DIR, 'MEMORY.md.bak');
|
|
103
|
+
// ============================================================================
|
|
104
|
+
// Pure helpers (no FS, clock only via injected `now`)
|
|
105
|
+
// ============================================================================
|
|
106
|
+
/** lowercase, punctuation→space, collapse whitespace. Keeps Thai letters + combining vowel/tone marks (\p{M}). */
|
|
107
|
+
export function normalize(text) {
|
|
108
|
+
return text
|
|
109
|
+
.toLowerCase()
|
|
110
|
+
.replace(/[^\p{L}\p{N}\p{M}\s]+/gu, ' ')
|
|
111
|
+
.replace(/\s+/g, ' ')
|
|
112
|
+
.trim();
|
|
113
|
+
}
|
|
114
|
+
// word segmenter (Node ≥22 ships full ICU) — segments space-less scripts like Thai so "ไม่" tokenizes on
|
|
115
|
+
// its own and reaches the merge/contradiction machinery. Built once; pure + deterministic, no network.
|
|
116
|
+
const WORD_SEG = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
117
|
+
/** token set (word-like segments, length > 1). Works for English AND space-less Thai. */
|
|
118
|
+
export function tokens(text) {
|
|
119
|
+
const out = new Set();
|
|
120
|
+
for (const seg of WORD_SEG.segment(normalize(text))) {
|
|
121
|
+
if (!seg.isWordLike)
|
|
122
|
+
continue;
|
|
123
|
+
const t = seg.segment.trim();
|
|
124
|
+
if (t.length > 1)
|
|
125
|
+
out.add(t);
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
/** content tokens that carry meaning (drop stopwords + negation) — used for "shared subject" tests. */
|
|
130
|
+
function subjectTokens(text) {
|
|
131
|
+
const out = new Set();
|
|
132
|
+
for (const t of tokens(text))
|
|
133
|
+
if (t.length >= 3 && !STOPWORDS.has(t) && !NEGATION.has(t))
|
|
134
|
+
out.add(t);
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
/** Jaccard similarity over token sets (0..1). Two empties ⇒ 0. */
|
|
138
|
+
export function sim(a, b) {
|
|
139
|
+
const A = tokens(a);
|
|
140
|
+
const B = tokens(b);
|
|
141
|
+
if (!A.size || !B.size)
|
|
142
|
+
return 0;
|
|
143
|
+
let inter = 0;
|
|
144
|
+
for (const t of A)
|
|
145
|
+
if (B.has(t))
|
|
146
|
+
inter++;
|
|
147
|
+
return inter / (A.size + B.size - inter);
|
|
148
|
+
}
|
|
149
|
+
/** stable content-hashed id: 'm_' + 6 url-safe chars of FNV-1a(normalize(text)). */
|
|
150
|
+
export function deriveId(text) {
|
|
151
|
+
const s = normalize(text);
|
|
152
|
+
let h = 0x811c9dc5;
|
|
153
|
+
for (let i = 0; i < s.length; i++) {
|
|
154
|
+
h ^= s.charCodeAt(i);
|
|
155
|
+
h = Math.imul(h, 0x01000193);
|
|
156
|
+
}
|
|
157
|
+
h >>>= 0;
|
|
158
|
+
const alpha = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
159
|
+
let out = '';
|
|
160
|
+
for (let i = 0; i < 6; i++) {
|
|
161
|
+
out += alpha[h & 63];
|
|
162
|
+
h = Math.floor(h / 64);
|
|
163
|
+
}
|
|
164
|
+
return `m_${out}`;
|
|
165
|
+
}
|
|
166
|
+
/** effective importance = stored importance decayed by recency, with an access-count floor. */
|
|
167
|
+
export function effImportance(f, now) {
|
|
168
|
+
const days = Math.max(0, (now - f.lastAccessed) / DAY_MS);
|
|
169
|
+
const recency = 0.5 ** (days / HALF_LIFE_DAYS);
|
|
170
|
+
const base = f.importance * recency;
|
|
171
|
+
const floor = Math.min(0.4, f.accessCount * 0.05);
|
|
172
|
+
return Math.max(base, floor);
|
|
173
|
+
}
|
|
174
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, n));
|
|
175
|
+
/** tri-state contradiction signal (deterministic, no LLM). Conservative: only "yes" on a clear polarity flip. */
|
|
176
|
+
function contradiction(a, b, sameCategory) {
|
|
177
|
+
if (!sameCategory)
|
|
178
|
+
return 'no';
|
|
179
|
+
const subjA = subjectTokens(a);
|
|
180
|
+
const subjB = subjectTokens(b);
|
|
181
|
+
let shared = false;
|
|
182
|
+
for (const t of subjA)
|
|
183
|
+
if (subjB.has(t)) {
|
|
184
|
+
shared = true;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (!shared)
|
|
188
|
+
return 'no';
|
|
189
|
+
const negA = [...tokens(a)].some((t) => NEGATION.has(t));
|
|
190
|
+
const negB = [...tokens(b)].some((t) => NEGATION.has(t));
|
|
191
|
+
if (negA !== negB)
|
|
192
|
+
return 'yes'; // one negates, the other affirms the same subject ⇒ clear flip
|
|
193
|
+
return 'ambiguous'; // same subject, differing detail, no clear flip ⇒ keep both, defer to owner
|
|
194
|
+
}
|
|
195
|
+
const CATEGORY = new Set(['preference', 'decision', 'convention']);
|
|
196
|
+
/** an empty version-2 store. */
|
|
197
|
+
export function emptyStore(_now = Date.now()) {
|
|
198
|
+
return { version: 2, meta: { lastConsolidated: 0, activeAtLastConsolidate: 0, migratedFrom: null }, facts: [] };
|
|
199
|
+
}
|
|
200
|
+
/** active (retrievable) facts. */
|
|
201
|
+
export function activeFacts(store) {
|
|
202
|
+
return store.facts.filter((f) => f.status === 'active');
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* a per-record-unique id: deriveId is content-hashed, but a SUPERSEDED fact keeps its id, so re-adding
|
|
206
|
+
* that text would mint a new active fact colliding with it. Disambiguate with a ~N suffix on collision,
|
|
207
|
+
* so supersededBy/supersedes edges and id-keyed lookups always resolve to exactly one record.
|
|
208
|
+
*/
|
|
209
|
+
function uniqueId(base, facts) {
|
|
210
|
+
if (!facts.some((f) => f.id === base))
|
|
211
|
+
return base;
|
|
212
|
+
for (let n = 2;; n++) {
|
|
213
|
+
const candidate = `${base}~${n}`;
|
|
214
|
+
if (!facts.some((f) => f.id === candidate))
|
|
215
|
+
return candidate;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function newFact(inc, now, facts = []) {
|
|
219
|
+
const noteType = inc.noteType ?? 'reference';
|
|
220
|
+
return {
|
|
221
|
+
id: uniqueId(deriveId(inc.text), facts),
|
|
222
|
+
text: inc.text,
|
|
223
|
+
noteType,
|
|
224
|
+
tier: inc.tier ?? 'durable',
|
|
225
|
+
trust: inc.trust ?? 'agent',
|
|
226
|
+
tags: [],
|
|
227
|
+
importance: PRIOR[noteType] ?? 0.5,
|
|
228
|
+
accessCount: 0,
|
|
229
|
+
status: 'active',
|
|
230
|
+
validFrom: now,
|
|
231
|
+
invalidatedAt: null,
|
|
232
|
+
supersededBy: null,
|
|
233
|
+
supersedes: [],
|
|
234
|
+
related: [],
|
|
235
|
+
parent: 'auto-memory',
|
|
236
|
+
source: inc.source ?? null,
|
|
237
|
+
created: now,
|
|
238
|
+
updated: now,
|
|
239
|
+
lastAccessed: now,
|
|
240
|
+
reviewAfter: null,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const tokenCount = (t) => tokens(t).size;
|
|
244
|
+
const withFacts = (store, facts) => ({ ...store, facts });
|
|
245
|
+
/**
|
|
246
|
+
* Merge an incoming fact into the store — Mem0-style ADD / UPDATE / NOOP / SUPERSEDE,
|
|
247
|
+
* plus PROTECTED_HALT and QUARANTINE. Deterministic, no network, pure (clock injected).
|
|
248
|
+
* redactKey is applied here too (defense in depth) before anything touches the text.
|
|
249
|
+
*/
|
|
250
|
+
export function mergeFact(store, incoming, now = Date.now()) {
|
|
251
|
+
const text = redactKey(incoming.text).trim().replace(/\s+/g, ' ');
|
|
252
|
+
// reject text whose NORMALIZED form is empty (punctuation/emoji-only) — it would collapse onto the
|
|
253
|
+
// shared deriveId('') key and let the first such fact silently swallow all later distinct ones.
|
|
254
|
+
if (!normalize(text))
|
|
255
|
+
return { store, op: 'NOOP', fact: null };
|
|
256
|
+
const inc = { ...incoming, text };
|
|
257
|
+
const noteType = inc.noteType ?? 'reference';
|
|
258
|
+
const sameCat = CATEGORY.has(noteType);
|
|
259
|
+
const facts = store.facts.slice();
|
|
260
|
+
// 1) PROTECTED GATE — never auto-supersede owner ground-truth; halt + flag on conflict.
|
|
261
|
+
for (const m of facts) {
|
|
262
|
+
if (m.tier !== 'protected' || m.status !== 'active')
|
|
263
|
+
continue;
|
|
264
|
+
if (sim(text, m.text) >= RELATED && contradiction(m.text, text, CATEGORY.has(m.noteType) && sameCat) === 'yes') {
|
|
265
|
+
return { store, op: 'PROTECTED_HALT', fact: null, flag: `conflicts with protected fact: ${m.text}` };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
// 2) PROVENANCE GATE — derived/untrusted text with an unresolved source is quarantined to the inbox tier.
|
|
269
|
+
if ((inc.trust === 'derived' || inc.trust === 'untrusted') && !inc.sourceResolved) {
|
|
270
|
+
const f = newFact(inc, now, facts);
|
|
271
|
+
f.tier = 'inbox';
|
|
272
|
+
f.reviewAfter = now + INBOX_TTL_MS;
|
|
273
|
+
facts.push(f);
|
|
274
|
+
return { store: withFacts(store, facts), op: 'QUARANTINE', fact: f };
|
|
275
|
+
}
|
|
276
|
+
const tier = inc.tier ?? 'durable';
|
|
277
|
+
const norm = normalize(text);
|
|
278
|
+
// 3a) EXACT normalized duplicate (same tier, active) ⇒ NOOP + touch.
|
|
279
|
+
// Robust even when token-Jaccard is 0 (e.g. 1-char facts) — keeps idempotent remember + bumps the importance signal.
|
|
280
|
+
const exactIdx = facts.findIndex((m) => m.status === 'active' && m.tier === tier && normalize(m.text) === norm);
|
|
281
|
+
if (exactIdx >= 0) {
|
|
282
|
+
const m = facts[exactIdx];
|
|
283
|
+
facts[exactIdx] = { ...m, accessCount: m.accessCount + 1, lastAccessed: now, updated: now };
|
|
284
|
+
return { store: withFacts(store, facts), op: 'NOOP', fact: facts[exactIdx] };
|
|
285
|
+
}
|
|
286
|
+
// 3b) FUZZY MATCH — best active fact in the SAME tier by token-Jaccard similarity.
|
|
287
|
+
let best;
|
|
288
|
+
let bestSim = 0;
|
|
289
|
+
for (const m of facts) {
|
|
290
|
+
if (m.status !== 'active' || m.tier !== tier)
|
|
291
|
+
continue;
|
|
292
|
+
const s = sim(text, m.text);
|
|
293
|
+
if (s > bestSim || (s === bestSim && best && m.created > best.created)) {
|
|
294
|
+
best = m;
|
|
295
|
+
bestSim = s;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const idx = best ? facts.indexOf(best) : -1;
|
|
299
|
+
if (best && bestSim >= NEAR_DUP) {
|
|
300
|
+
// near-dup but not identical ⇒ UPDATE in place, keep id, longer text wins
|
|
301
|
+
const longerWins = tokenCount(text) > tokenCount(best.text) ? text : best.text;
|
|
302
|
+
const updated = {
|
|
303
|
+
...best,
|
|
304
|
+
text: longerWins,
|
|
305
|
+
importance: clamp01(Math.max(best.importance, PRIOR[noteType] ?? 0.5) + 0.05),
|
|
306
|
+
accessCount: best.accessCount + 1,
|
|
307
|
+
updated: now,
|
|
308
|
+
lastAccessed: now,
|
|
309
|
+
};
|
|
310
|
+
facts[idx] = updated;
|
|
311
|
+
return { store: withFacts(store, facts), op: 'UPDATE', fact: updated };
|
|
312
|
+
}
|
|
313
|
+
if (best && bestSim >= RELATED) {
|
|
314
|
+
const c = contradiction(best.text, text, CATEGORY.has(best.noteType) && sameCat);
|
|
315
|
+
if (c === 'yes') {
|
|
316
|
+
// SUPERSEDE — bi-temporal soft-delete: old stays queryable, new points back to it
|
|
317
|
+
const fresh = newFact(inc, now, facts);
|
|
318
|
+
fresh.supersedes = [best.id];
|
|
319
|
+
fresh.related = [best.id];
|
|
320
|
+
fresh.importance = Math.max(0.55, best.importance);
|
|
321
|
+
facts[idx] = { ...best, status: 'superseded', invalidatedAt: now, supersededBy: fresh.id, updated: now };
|
|
322
|
+
facts.push(fresh);
|
|
323
|
+
return { store: withFacts(store, facts), op: 'SUPERSEDE', fact: fresh };
|
|
324
|
+
}
|
|
325
|
+
// related (or ambiguous contradiction) ⇒ ADD but link + flag for review, never silently merge/supersede
|
|
326
|
+
const fresh = newFact(inc, now, facts);
|
|
327
|
+
fresh.related = [best.id];
|
|
328
|
+
if (c === 'ambiguous')
|
|
329
|
+
fresh.reviewAfter = now + INBOX_TTL_MS;
|
|
330
|
+
facts.push(fresh);
|
|
331
|
+
return { store: withFacts(store, facts), op: 'ADD', fact: fresh };
|
|
332
|
+
}
|
|
333
|
+
// 4) genuinely new ⇒ ADD
|
|
334
|
+
const fresh = newFact(inc, now, facts);
|
|
335
|
+
facts.push(fresh);
|
|
336
|
+
return { store: withFacts(store, facts), op: 'ADD', fact: fresh };
|
|
337
|
+
}
|
|
338
|
+
/** stable iteration order so consolidate() is idempotent. */
|
|
339
|
+
function byCreatedThenId(a, b) {
|
|
340
|
+
return a.created - b.created || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Sleep-time consolidation — pure, idempotent. Touch-decay → archive → merge overlapping →
|
|
344
|
+
* inbox drain/TTL → promote recurring → stamp meta. Running twice is a no-op.
|
|
345
|
+
*/
|
|
346
|
+
export function consolidate(store, now = Date.now()) {
|
|
347
|
+
const report = { archived: [], merged: [], promoted: [], needsReview: [] };
|
|
348
|
+
let facts = store.facts.slice().sort(byCreatedThenId);
|
|
349
|
+
// STEP 1 — ARCHIVE stale, untouched, low-value (soft delete; protected exempt)
|
|
350
|
+
facts = facts.map((f) => {
|
|
351
|
+
if (f.status === 'active' &&
|
|
352
|
+
f.tier !== 'protected' &&
|
|
353
|
+
f.reviewAfter !== null &&
|
|
354
|
+
now > f.reviewAfter &&
|
|
355
|
+
f.accessCount === 0 &&
|
|
356
|
+
effImportance(f, now) < ARCHIVE_FLOOR) {
|
|
357
|
+
report.archived.push(f.id);
|
|
358
|
+
return { ...f, status: 'archived', invalidatedAt: now };
|
|
359
|
+
}
|
|
360
|
+
return f;
|
|
361
|
+
});
|
|
362
|
+
// STEP 2 — MERGE OVERLAPPING active facts that escaped inline merge (fold younger into oldest)
|
|
363
|
+
const removed = new Set();
|
|
364
|
+
for (let i = 0; i < facts.length; i++) {
|
|
365
|
+
if (removed.has(i) || facts[i].status !== 'active')
|
|
366
|
+
continue;
|
|
367
|
+
for (let j = i + 1; j < facts.length; j++) {
|
|
368
|
+
const keep = facts[i];
|
|
369
|
+
const dup = facts[j];
|
|
370
|
+
if (removed.has(j) || dup.status !== 'active' || keep.tier !== dup.tier)
|
|
371
|
+
continue;
|
|
372
|
+
if (sim(keep.text, dup.text) >= NEAR_DUP) {
|
|
373
|
+
facts[i] = {
|
|
374
|
+
...keep,
|
|
375
|
+
text: tokenCount(keep.text) >= tokenCount(dup.text) ? keep.text : dup.text,
|
|
376
|
+
importance: clamp01(Math.max(keep.importance, dup.importance)),
|
|
377
|
+
accessCount: keep.accessCount + dup.accessCount,
|
|
378
|
+
tags: [...new Set([...keep.tags, ...dup.tags])],
|
|
379
|
+
updated: now,
|
|
380
|
+
};
|
|
381
|
+
removed.add(j);
|
|
382
|
+
report.merged.push(dup.id);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (removed.size)
|
|
387
|
+
facts = facts.filter((_, idx) => !removed.has(idx));
|
|
388
|
+
// STEP 3 — INBOX DRAIN / TTL: promote agent-origin items past TTL, flag untrusted for review
|
|
389
|
+
const durableTexts = facts.filter((f) => f.status === 'active' && f.tier === 'durable').map((f) => f.text);
|
|
390
|
+
facts = facts.map((f) => {
|
|
391
|
+
if (f.status !== 'active' || f.tier !== 'inbox' || now < f.created + INBOX_TTL_MS)
|
|
392
|
+
return f;
|
|
393
|
+
const hasDurableDup = durableTexts.some((t) => sim(t, f.text) >= NEAR_DUP);
|
|
394
|
+
if (f.trust !== 'untrusted' && !hasDurableDup) {
|
|
395
|
+
report.promoted.push(f.id);
|
|
396
|
+
return { ...f, tier: 'durable', reviewAfter: null, updated: now };
|
|
397
|
+
}
|
|
398
|
+
if (f.reviewAfter !== now)
|
|
399
|
+
report.needsReview.push(f.id); // only on the actual transition ⇒ report is idempotent
|
|
400
|
+
return { ...f, reviewAfter: now };
|
|
401
|
+
});
|
|
402
|
+
// STEP 4 — PROMOTE recurring (accessed ≥ 3) to an importance floor (idempotent: max, not increment)
|
|
403
|
+
facts = facts.map((f) => {
|
|
404
|
+
if (f.status === 'active' && f.tier !== 'protected' && f.accessCount >= 3 && f.importance < 0.8) {
|
|
405
|
+
if (!report.promoted.includes(f.id))
|
|
406
|
+
report.promoted.push(f.id); // don't double-list an inbox-promoted id
|
|
407
|
+
return { ...f, importance: 0.8 };
|
|
408
|
+
}
|
|
409
|
+
return f;
|
|
410
|
+
});
|
|
411
|
+
const next = {
|
|
412
|
+
...store,
|
|
413
|
+
facts,
|
|
414
|
+
meta: { ...store.meta, lastConsolidated: now, activeAtLastConsolidate: facts.filter((f) => f.status === 'active').length },
|
|
415
|
+
};
|
|
416
|
+
return { store: next, report };
|
|
417
|
+
}
|
|
418
|
+
/** should consolidation run? (cadence only — caller also guards persistenceEnabled). */
|
|
419
|
+
export function maybeConsolidate(store, now = Date.now()) {
|
|
420
|
+
const activeN = activeFacts(store).length;
|
|
421
|
+
return now - store.meta.lastConsolidated >= CONSOLIDATE_EVERY_MS || activeN - store.meta.activeAtLastConsolidate >= CONSOLIDATE_EVERY_N;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* rank active facts for retrieval/render: protected first, then effective importance, then recency, then id.
|
|
425
|
+
* Excludes tier 'inbox' (quarantined/un-vetted) — those must NEVER reach the system prompt or MEMORY.md.
|
|
426
|
+
*/
|
|
427
|
+
function ranked(store, now) {
|
|
428
|
+
return activeFacts(store)
|
|
429
|
+
.filter((f) => f.tier !== 'inbox')
|
|
430
|
+
.sort((a, b) => {
|
|
431
|
+
const pa = a.tier === 'protected' ? 1 : 0;
|
|
432
|
+
const pb = b.tier === 'protected' ? 1 : 0;
|
|
433
|
+
return pb - pa || effImportance(b, now) - effImportance(a, now) || b.updated - a.updated || (a.id < b.id ? -1 : 1);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
/** the human/git Markdown view (full active set, ranked). */
|
|
437
|
+
export function renderView(store, now = Date.now()) {
|
|
438
|
+
const lines = ranked(store, now).map((f) => `- ${f.text}`);
|
|
439
|
+
return `# ${BRAND.autoMemoryTitle}\n\n${lines.join('\n')}\n`;
|
|
440
|
+
}
|
|
441
|
+
/** the system-prompt block: '' when empty, else a single capped, head-biased <auto_memory> block. */
|
|
442
|
+
export function renderPromptBlock(store, now = Date.now()) {
|
|
443
|
+
const picked = [];
|
|
444
|
+
let size = 0;
|
|
445
|
+
for (const f of ranked(store, now)) {
|
|
446
|
+
const line = `- ${f.text}`;
|
|
447
|
+
if (picked.length && size + line.length + 1 > PROMPT_CAP)
|
|
448
|
+
break;
|
|
449
|
+
picked.push(line);
|
|
450
|
+
size += line.length + 1;
|
|
451
|
+
}
|
|
452
|
+
if (!picked.length)
|
|
453
|
+
return '';
|
|
454
|
+
return `<auto_memory note="${PROMPT_NOTE}">\n${picked.join('\n')}\n</auto_memory>`;
|
|
455
|
+
}
|
|
456
|
+
/** classify a legacy bare line into a note type (tiny keyword heuristic). */
|
|
457
|
+
function inferNoteType(text) {
|
|
458
|
+
const l = text.toLowerCase();
|
|
459
|
+
if (/(ชอบ|prefer|likes?|favou?rite)/.test(l))
|
|
460
|
+
return 'preference';
|
|
461
|
+
if (/(ตัดสินใจ|decided|decision|chose|switch)/.test(l))
|
|
462
|
+
return 'decision';
|
|
463
|
+
if (/(convention|always|เสมอ|ทุกครั้ง|never)/.test(l))
|
|
464
|
+
return 'convention';
|
|
465
|
+
return 'reference';
|
|
466
|
+
}
|
|
467
|
+
/** one-time, idempotent, lossless migration of the flat "# title \n - fact" markdown into a store. */
|
|
468
|
+
export function migrateFromFlat(md, now = Date.now()) {
|
|
469
|
+
let store = emptyStore();
|
|
470
|
+
const header = `# ${BRAND.autoMemoryTitle}`;
|
|
471
|
+
for (const raw of md.split('\n')) {
|
|
472
|
+
const line = raw.trim();
|
|
473
|
+
if (!line || line === header || line.startsWith('#'))
|
|
474
|
+
continue;
|
|
475
|
+
const text = redactKey(line.replace(/^[-*]\s+/, '').trim()).replace(/\s+/g, ' ');
|
|
476
|
+
if (!text)
|
|
477
|
+
continue;
|
|
478
|
+
store = mergeFact(store, { text, trust: 'agent', noteType: inferNoteType(text) }, now).store;
|
|
479
|
+
}
|
|
480
|
+
store.meta.migratedFrom = 'v1-flat';
|
|
481
|
+
return store;
|
|
482
|
+
}
|
|
483
|
+
// ============================================================================
|
|
484
|
+
// FS boundary — the ONLY place that touches disk. Honors persistenceEnabled,
|
|
485
|
+
// 0o600 permissions, and atomic write (tmp+rename). loadStore never writes.
|
|
486
|
+
// ============================================================================
|
|
487
|
+
async function exists(p) {
|
|
488
|
+
try {
|
|
489
|
+
await stat(p);
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Load the store. Reads memory.json (source of truth) if present and valid; else lazily
|
|
498
|
+
* migrates a legacy MEMORY.md IN MEMORY (no write — read paths stay pure); else empty.
|
|
499
|
+
* An unreadable/wrong-version json degrades gracefully rather than crashing the agent.
|
|
500
|
+
*/
|
|
501
|
+
export async function loadStore(now = Date.now()) {
|
|
502
|
+
try {
|
|
503
|
+
const parsed = StoreSchema.safeParse(JSON.parse(await readFile(MEMORY_JSON, 'utf8')));
|
|
504
|
+
if (parsed.success)
|
|
505
|
+
return parsed.data;
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
/* no json yet, or malformed → fall through */
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const md = await readFile(AUTO_MEMORY_FILE, 'utf8');
|
|
512
|
+
if (md.trim())
|
|
513
|
+
return migrateFromFlat(md, now); // legacy file → migrate in memory, persisted on next write
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
/* no legacy file either */
|
|
517
|
+
}
|
|
518
|
+
return emptyStore();
|
|
519
|
+
}
|
|
520
|
+
async function writeSecure(path, content) {
|
|
521
|
+
// write to a fresh tmp inode (so the 0o600 mode is honored even over a pre-existing 0o644 file —
|
|
522
|
+
// writeFile's mode is ignored when the file already exists) then atomically rename into place.
|
|
523
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
524
|
+
try {
|
|
525
|
+
await writeFile(tmp, content, { mode: 0o600 });
|
|
526
|
+
await chmod(tmp, 0o600).catch(() => { });
|
|
527
|
+
await rename(tmp, path);
|
|
528
|
+
}
|
|
529
|
+
catch (e) {
|
|
530
|
+
await rm(tmp, { force: true }).catch(() => { });
|
|
531
|
+
throw e;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Persist the store: memory.json via tmp+rename (atomic), then re-render MEMORY.md.
|
|
536
|
+
* Both files are 0o600. On the very first json write, the legacy MEMORY.md is backed up
|
|
537
|
+
* to MEMORY.md.bak so raw legacy text is never destroyed. No-op when persistence is disabled.
|
|
538
|
+
*/
|
|
539
|
+
export async function saveStore(store, now = Date.now()) {
|
|
540
|
+
if (!persistenceEnabled())
|
|
541
|
+
return;
|
|
542
|
+
await mkdir(MEMORY_DIR, { recursive: true });
|
|
543
|
+
const firstJson = !(await exists(MEMORY_JSON));
|
|
544
|
+
if (firstJson && (await exists(AUTO_MEMORY_FILE))) {
|
|
545
|
+
await copyFile(AUTO_MEMORY_FILE, MEMORY_BAK).catch(() => { });
|
|
546
|
+
await chmod(MEMORY_BAK, 0o600).catch(() => { });
|
|
547
|
+
}
|
|
548
|
+
const tmp = join(MEMORY_DIR, `memory.${randomUUID()}.tmp`);
|
|
549
|
+
try {
|
|
550
|
+
await writeFile(tmp, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
551
|
+
await chmod(tmp, 0o600).catch(() => { });
|
|
552
|
+
await rename(tmp, MEMORY_JSON);
|
|
553
|
+
}
|
|
554
|
+
catch (e) {
|
|
555
|
+
await rm(tmp, { force: true }).catch(() => { });
|
|
556
|
+
throw e;
|
|
557
|
+
}
|
|
558
|
+
await writeSecure(AUTO_MEMORY_FILE, renderView(store, now));
|
|
559
|
+
}
|