opencode-harnmem 0.0.0-stage → 0.1.0
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 -0
- package/README.md +45 -2
- package/index.js +512 -0
- package/lib/engine.mjs +72 -0
- package/lib/format.mjs +119 -0
- package/lib/settings.mjs +131 -0
- package/package.json +23 -3
- package/vendor/core/assets.d.ts +52 -0
- package/vendor/core/assets.d.ts.map +1 -0
- package/vendor/core/assets.js +227 -0
- package/vendor/core/assets.js.map +1 -0
- package/vendor/core/config.d.ts +36 -0
- package/vendor/core/config.d.ts.map +1 -0
- package/vendor/core/config.js +134 -0
- package/vendor/core/config.js.map +1 -0
- package/vendor/core/consolidate.d.ts +14 -0
- package/vendor/core/consolidate.d.ts.map +1 -0
- package/vendor/core/consolidate.js +237 -0
- package/vendor/core/consolidate.js.map +1 -0
- package/vendor/core/data/synonyms.en.txt +61 -0
- package/vendor/core/data/synonyms.zh.txt +157 -0
- package/vendor/core/extract.d.ts +49 -0
- package/vendor/core/extract.d.ts.map +1 -0
- package/vendor/core/extract.js +179 -0
- package/vendor/core/extract.js.map +1 -0
- package/vendor/core/frontmatter.d.ts +31 -0
- package/vendor/core/frontmatter.d.ts.map +1 -0
- package/vendor/core/frontmatter.js +372 -0
- package/vendor/core/frontmatter.js.map +1 -0
- package/vendor/core/harnmem.d.ts +87 -0
- package/vendor/core/harnmem.d.ts.map +1 -0
- package/vendor/core/harnmem.js +918 -0
- package/vendor/core/harnmem.js.map +1 -0
- package/vendor/core/hot-context.d.ts +28 -0
- package/vendor/core/hot-context.d.ts.map +1 -0
- package/vendor/core/hot-context.js +179 -0
- package/vendor/core/hot-context.js.map +1 -0
- package/vendor/core/index.d.ts +16 -0
- package/vendor/core/index.d.ts.map +1 -0
- package/vendor/core/index.js +10 -0
- package/vendor/core/index.js.map +1 -0
- package/vendor/core/indexer.d.ts +107 -0
- package/vendor/core/indexer.d.ts.map +1 -0
- package/vendor/core/indexer.js +282 -0
- package/vendor/core/indexer.js.map +1 -0
- package/vendor/core/inspect.d.ts +278 -0
- package/vendor/core/inspect.d.ts.map +1 -0
- package/vendor/core/inspect.js +643 -0
- package/vendor/core/inspect.js.map +1 -0
- package/vendor/core/lifecycle.d.ts +50 -0
- package/vendor/core/lifecycle.d.ts.map +1 -0
- package/vendor/core/lifecycle.js +249 -0
- package/vendor/core/lifecycle.js.map +1 -0
- package/vendor/core/project.d.ts +41 -0
- package/vendor/core/project.d.ts.map +1 -0
- package/vendor/core/project.js +197 -0
- package/vendor/core/project.js.map +1 -0
- package/vendor/core/prompts/README.md +96 -0
- package/vendor/core/prompts/consolidate-digest.md +31 -0
- package/vendor/core/prompts/extract.md +80 -0
- package/vendor/core/prompts/migrate.md +96 -0
- package/vendor/core/prompts/synonyms.md +43 -0
- package/vendor/core/prompts/write-policy.md +12 -0
- package/vendor/core/retrieval.d.ts +95 -0
- package/vendor/core/retrieval.d.ts.map +1 -0
- package/vendor/core/retrieval.js +569 -0
- package/vendor/core/retrieval.js.map +1 -0
- package/vendor/core/store.d.ts +145 -0
- package/vendor/core/store.d.ts.map +1 -0
- package/vendor/core/store.js +511 -0
- package/vendor/core/store.js.map +1 -0
- package/vendor/core/suggest.d.ts +128 -0
- package/vendor/core/suggest.d.ts.map +1 -0
- package/vendor/core/suggest.js +384 -0
- package/vendor/core/suggest.js.map +1 -0
- package/vendor/core/text.d.ts +80 -0
- package/vendor/core/text.d.ts.map +1 -0
- package/vendor/core/text.js +253 -0
- package/vendor/core/text.js.map +1 -0
- package/vendor/core/types.d.ts +453 -0
- package/vendor/core/types.d.ts.map +1 -0
- package/vendor/core/types.js +14 -0
- package/vendor/core/types.js.map +1 -0
- package/vendor/core/util.d.ts +38 -0
- package/vendor/core/util.d.ts.map +1 -0
- package/vendor/core/util.js +248 -0
- package/vendor/core/util.js.map +1 -0
|
@@ -0,0 +1,643 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only projection of a HarnMem home.
|
|
3
|
+
*
|
|
4
|
+
* Presentation layers (the DSH sidebar panel, CLI `harnmem inspector`, future
|
|
5
|
+
* web UI) need to answer "what does the memory system currently hold?". Every
|
|
6
|
+
* other consumer of the store goes through the engine, which needs an index,
|
|
7
|
+
* a retriever and a write pipeline; a viewer must not.
|
|
8
|
+
*
|
|
9
|
+
* Design constraints, deliberately enforced here:
|
|
10
|
+
*
|
|
11
|
+
* - **No engine, no writes.** `HarnMem.open()` registers the project and
|
|
12
|
+
* deploys prompt assets, i.e. it mutates `projects.json`. A viewer that is
|
|
13
|
+
* opened just to look around must not touch disk, and a second engine over
|
|
14
|
+
* the same home would race the adapter's engine.
|
|
15
|
+
* - **No index dependency.** Records are read from their markdown files
|
|
16
|
+
* (`frontmatter` is the source of truth). `index/<shard>.json` is only
|
|
17
|
+
* *reported* (file size, staleness), never required; its `docTerms` /
|
|
18
|
+
* `postings` payload is far too large to ship to a browser.
|
|
19
|
+
* - **No async store bootstrap.** `Store.init()` is not called: missing
|
|
20
|
+
* directories and files degrade to empty results instead of being created.
|
|
21
|
+
*
|
|
22
|
+
* Byte-sized reads are cached for a very short TTL so a single HTTP request
|
|
23
|
+
* that needs the same shard twice (overview + list) reads the directory once.
|
|
24
|
+
*/
|
|
25
|
+
import { promises as fs } from "node:fs";
|
|
26
|
+
import { basename, join, relative } from "node:path";
|
|
27
|
+
import { DEFAULT_BUDGET } from "./config.js";
|
|
28
|
+
import { parseFrontmatter, recordFromFrontmatter } from "./frontmatter.js";
|
|
29
|
+
import { renderHotContextSync } from "./hot-context.js";
|
|
30
|
+
import { loadProjects } from "./project.js";
|
|
31
|
+
import { Store, shardKey } from "./store.js";
|
|
32
|
+
import { estimateTokens, readJson, readTextIfExists, resolveHome, truncateText } from "./util.js";
|
|
33
|
+
/** Default shard cache TTL: long enough to dedupe one request, short enough to stay live. */
|
|
34
|
+
const DEFAULT_CACHE_TTL_MS = 250;
|
|
35
|
+
/** Registry cache TTL (`projects.json` changes rarely). */
|
|
36
|
+
const REGISTRY_CACHE_TTL_MS = 1000;
|
|
37
|
+
/** Characters of body shipped as a list preview. */
|
|
38
|
+
const PREVIEW_CHARS = 240;
|
|
39
|
+
function messageOf(error) {
|
|
40
|
+
return error instanceof Error ? error.message : String(error);
|
|
41
|
+
}
|
|
42
|
+
function posix(path) {
|
|
43
|
+
return path.replace(/\\/g, "/");
|
|
44
|
+
}
|
|
45
|
+
function sortRows(a, b) {
|
|
46
|
+
if (a.pinned !== b.pinned)
|
|
47
|
+
return a.pinned ? -1 : 1;
|
|
48
|
+
if (a.importance !== b.importance)
|
|
49
|
+
return b.importance - a.importance;
|
|
50
|
+
if (a.updated !== b.updated)
|
|
51
|
+
return b.updated - a.updated;
|
|
52
|
+
if (a.created !== b.created)
|
|
53
|
+
return b.created - a.created;
|
|
54
|
+
return a.id.localeCompare(b.id);
|
|
55
|
+
}
|
|
56
|
+
function bump(map, key) {
|
|
57
|
+
map[key] = (map[key] ?? 0) + 1;
|
|
58
|
+
}
|
|
59
|
+
/** `harnmem` project ids look like `<slug>-<10 chars>`; keep the slug part for display. */
|
|
60
|
+
function fallbackProjectName(id) {
|
|
61
|
+
const match = /^(.*)-[0-9a-z]{6,}$/u.exec(id);
|
|
62
|
+
return match?.[1] ? match[1] : id;
|
|
63
|
+
}
|
|
64
|
+
function registryEntries(file) {
|
|
65
|
+
const byId = new Map();
|
|
66
|
+
for (const entry of Object.values(file.projects ?? {})) {
|
|
67
|
+
if (!entry || typeof entry.id !== "string")
|
|
68
|
+
continue;
|
|
69
|
+
if (!byId.has(entry.id))
|
|
70
|
+
byId.set(entry.id, entry);
|
|
71
|
+
}
|
|
72
|
+
return [...byId.values()];
|
|
73
|
+
}
|
|
74
|
+
function projectNameMap(file) {
|
|
75
|
+
const map = new Map();
|
|
76
|
+
for (const entry of registryEntries(file))
|
|
77
|
+
map.set(entry.id, entry.name || fallbackProjectName(entry.id));
|
|
78
|
+
return map;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Read-only view over one HarnMem home.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* ```ts
|
|
85
|
+
* const inspector = await MemoryInspector.open({ home });
|
|
86
|
+
* const overview = await inspector.overview({ cwd });
|
|
87
|
+
* const list = await inspector.list({ shard: "all" });
|
|
88
|
+
* await inspector.close();
|
|
89
|
+
* ```
|
|
90
|
+
*/
|
|
91
|
+
export class MemoryInspector {
|
|
92
|
+
/** Resolved HarnMem home (absolute). */
|
|
93
|
+
home;
|
|
94
|
+
/** Underlying store handle; only its read methods are used. */
|
|
95
|
+
store;
|
|
96
|
+
fixedNow;
|
|
97
|
+
cacheTtlMs;
|
|
98
|
+
snapshots = new Map();
|
|
99
|
+
registryCache = null;
|
|
100
|
+
constructor(options) {
|
|
101
|
+
this.home = options.home;
|
|
102
|
+
this.store = new Store(options.home);
|
|
103
|
+
this.fixedNow = options.now ?? null;
|
|
104
|
+
this.cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Open an inspector over `home` (resolved like the engine: explicit option,
|
|
108
|
+
* then `HARNMEM_HOME`, then `~/.harnmem`). Never writes to disk.
|
|
109
|
+
*/
|
|
110
|
+
static async open(options = {}) {
|
|
111
|
+
return new MemoryInspector({
|
|
112
|
+
home: resolveHome(options.home),
|
|
113
|
+
now: options.now,
|
|
114
|
+
cacheTtlMs: options.cacheTtlMs,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** Drop cached shards and registry state. */
|
|
118
|
+
async close() {
|
|
119
|
+
this.snapshots.clear();
|
|
120
|
+
this.registryCache = null;
|
|
121
|
+
}
|
|
122
|
+
clock() {
|
|
123
|
+
return this.fixedNow ?? Date.now();
|
|
124
|
+
}
|
|
125
|
+
async registry() {
|
|
126
|
+
const now = this.clock();
|
|
127
|
+
const ttl = Math.max(this.cacheTtlMs, REGISTRY_CACHE_TTL_MS);
|
|
128
|
+
if (this.registryCache && now - this.registryCache.at < ttl)
|
|
129
|
+
return this.registryCache.file;
|
|
130
|
+
const file = await loadProjects(this.home);
|
|
131
|
+
this.registryCache = { at: now, file };
|
|
132
|
+
return file;
|
|
133
|
+
}
|
|
134
|
+
async snapshot(shard) {
|
|
135
|
+
const key = shardKey(shard);
|
|
136
|
+
const now = this.clock();
|
|
137
|
+
const cached = this.snapshots.get(key);
|
|
138
|
+
if (cached && this.cacheTtlMs > 0 && now - cached.loadedAt < this.cacheTtlMs)
|
|
139
|
+
return cached;
|
|
140
|
+
const dir = this.store.shardDir(shard);
|
|
141
|
+
let files = [];
|
|
142
|
+
try {
|
|
143
|
+
files = (await fs.readdir(dir)).filter((file) => file.endsWith(".md") && file !== "MEMORY.md").sort();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
files = [];
|
|
147
|
+
}
|
|
148
|
+
const errors = [];
|
|
149
|
+
const loaded = await Promise.all(files.map(async (file) => {
|
|
150
|
+
try {
|
|
151
|
+
return { file, raw: await fs.readFile(join(dir, file), "utf8") };
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
errors.push({ file, message: messageOf(error) });
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}));
|
|
158
|
+
const records = [];
|
|
159
|
+
const sizes = new Map();
|
|
160
|
+
for (const item of loaded) {
|
|
161
|
+
if (!item)
|
|
162
|
+
continue;
|
|
163
|
+
const parsed = parseFrontmatter(item.raw);
|
|
164
|
+
let record;
|
|
165
|
+
try {
|
|
166
|
+
record = recordFromFrontmatter(parsed.data, parsed.body, basename(item.file, ".md"), 0);
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
errors.push({ file: item.file, message: messageOf(error) });
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (!record.id)
|
|
173
|
+
record.id = basename(item.file, ".md");
|
|
174
|
+
for (const message of parsed.errors)
|
|
175
|
+
errors.push({ file: item.file, message });
|
|
176
|
+
records.push(record);
|
|
177
|
+
sizes.set(record.id, Buffer.byteLength(item.raw, "utf8"));
|
|
178
|
+
}
|
|
179
|
+
records.sort((a, b) => a.created - b.created || a.id.localeCompare(b.id));
|
|
180
|
+
const indexText = await readTextIfExists(this.store.indexFile(shard));
|
|
181
|
+
const snapshot = {
|
|
182
|
+
shard,
|
|
183
|
+
records,
|
|
184
|
+
sizes,
|
|
185
|
+
errors,
|
|
186
|
+
indexText,
|
|
187
|
+
indexBytes: indexText ? Buffer.byteLength(indexText, "utf8") : 0,
|
|
188
|
+
loadedAt: now,
|
|
189
|
+
};
|
|
190
|
+
this.snapshots.set(key, snapshot);
|
|
191
|
+
return snapshot;
|
|
192
|
+
}
|
|
193
|
+
async shardsFor(name) {
|
|
194
|
+
const shards = await this.store.listShards();
|
|
195
|
+
if (!name || name === "all")
|
|
196
|
+
return shards;
|
|
197
|
+
return shards.filter((shard) => shard.name === name);
|
|
198
|
+
}
|
|
199
|
+
labelOf(shard, names) {
|
|
200
|
+
if (shard.scope === "user")
|
|
201
|
+
return "user";
|
|
202
|
+
const id = shard.project ?? shard.name;
|
|
203
|
+
return names.get(id) ?? fallbackProjectName(id);
|
|
204
|
+
}
|
|
205
|
+
row(record, shard, names, bytes) {
|
|
206
|
+
const project = record.project ?? (shard.scope === "project" ? shard.project ?? null : null);
|
|
207
|
+
const projectName = record.projectName ?? (project ? names.get(project) ?? fallbackProjectName(project) : null);
|
|
208
|
+
return {
|
|
209
|
+
id: record.id,
|
|
210
|
+
type: record.type,
|
|
211
|
+
category: record.category ?? null,
|
|
212
|
+
scope: record.scope,
|
|
213
|
+
project,
|
|
214
|
+
projectName,
|
|
215
|
+
title: record.title,
|
|
216
|
+
description: record.description,
|
|
217
|
+
preview: truncateText(record.content.replace(/\s+/gu, " ").trim(), PREVIEW_CHARS),
|
|
218
|
+
tags: record.tags,
|
|
219
|
+
entities: record.entities,
|
|
220
|
+
importance: record.importance,
|
|
221
|
+
confidence: record.confidence,
|
|
222
|
+
pinned: record.pinned,
|
|
223
|
+
status: record.status,
|
|
224
|
+
created: record.created,
|
|
225
|
+
updated: record.updated,
|
|
226
|
+
lastUsed: record.lastUsed ?? null,
|
|
227
|
+
usedCount: record.usedCount,
|
|
228
|
+
validFrom: record.validFrom,
|
|
229
|
+
validTo: record.validTo,
|
|
230
|
+
supersededBy: record.supersededBy,
|
|
231
|
+
links: record.links,
|
|
232
|
+
supersedes: record.supersedes,
|
|
233
|
+
evidence: record.evidence,
|
|
234
|
+
bytes,
|
|
235
|
+
tokens: estimateTokens(record.content),
|
|
236
|
+
harness: record.source?.harness ?? "unknown",
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
renderContext(options, names) {
|
|
240
|
+
const budgetTokens = options.budgetTokens ?? DEFAULT_BUDGET.injectTokens;
|
|
241
|
+
const hotEntries = options.hotEntries ?? DEFAULT_BUDGET.hotEntries;
|
|
242
|
+
const budget = { ...DEFAULT_BUDGET, injectTokens: budgetTokens, hotEntries };
|
|
243
|
+
const result = renderHotContextSync({
|
|
244
|
+
home: this.home,
|
|
245
|
+
cwd: options.cwd,
|
|
246
|
+
now: this.clock(),
|
|
247
|
+
config: { budget },
|
|
248
|
+
});
|
|
249
|
+
const projectName = result.projectId ? names.get(result.projectId) ?? fallbackProjectName(result.projectId) : null;
|
|
250
|
+
return {
|
|
251
|
+
cwd: options.cwd ?? null,
|
|
252
|
+
projectId: result.projectId,
|
|
253
|
+
projectName,
|
|
254
|
+
shards: result.shards,
|
|
255
|
+
text: result.text,
|
|
256
|
+
ids: result.ids,
|
|
257
|
+
tokensEstimate: result.tokensEstimate,
|
|
258
|
+
bytes: Buffer.byteLength(result.text, "utf8"),
|
|
259
|
+
budgetTokens,
|
|
260
|
+
hotEntries,
|
|
261
|
+
atBudget: result.tokensEstimate >= budgetTokens,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/** All project registry rows, merged with on-disk shard totals. */
|
|
265
|
+
async projects() {
|
|
266
|
+
const registry = await this.registry();
|
|
267
|
+
const shards = await this.store.listShards();
|
|
268
|
+
const snapshots = await Promise.all(shards.map((shard) => this.snapshot(shard)));
|
|
269
|
+
const merged = new Map();
|
|
270
|
+
for (const entry of registryEntries(registry)) {
|
|
271
|
+
merged.set(entry.id, {
|
|
272
|
+
id: entry.id,
|
|
273
|
+
slug: entry.slug ?? entry.id,
|
|
274
|
+
name: entry.name || fallbackProjectName(entry.id),
|
|
275
|
+
paths: Array.isArray(entry.paths) ? entry.paths : [],
|
|
276
|
+
harnesses: Array.isArray(entry.harnesses) ? entry.harnesses : [],
|
|
277
|
+
registered: true,
|
|
278
|
+
shards: [],
|
|
279
|
+
total: 0,
|
|
280
|
+
pinned: 0,
|
|
281
|
+
lastUpdated: null,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
for (const snapshot of snapshots) {
|
|
285
|
+
if (snapshot.shard.scope !== "project" || !snapshot.shard.project)
|
|
286
|
+
continue;
|
|
287
|
+
const id = snapshot.shard.project;
|
|
288
|
+
const row = merged.get(id) ?? {
|
|
289
|
+
id,
|
|
290
|
+
slug: id,
|
|
291
|
+
name: fallbackProjectName(id),
|
|
292
|
+
paths: [],
|
|
293
|
+
harnesses: [],
|
|
294
|
+
registered: false,
|
|
295
|
+
shards: [],
|
|
296
|
+
total: 0,
|
|
297
|
+
pinned: 0,
|
|
298
|
+
lastUpdated: null,
|
|
299
|
+
};
|
|
300
|
+
row.shards.push(snapshot.shard.name);
|
|
301
|
+
row.total += snapshot.records.length;
|
|
302
|
+
row.pinned += snapshot.records.filter((record) => record.pinned && record.status === "active").length;
|
|
303
|
+
for (const record of snapshot.records) {
|
|
304
|
+
if (row.lastUpdated === null || record.updated > row.lastUpdated)
|
|
305
|
+
row.lastUpdated = record.updated;
|
|
306
|
+
}
|
|
307
|
+
merged.set(id, row);
|
|
308
|
+
}
|
|
309
|
+
return [...merged.values()].sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0) || a.name.localeCompare(b.name));
|
|
310
|
+
}
|
|
311
|
+
/** Whole-home snapshot used by the overview screen. */
|
|
312
|
+
async overview(options = {}) {
|
|
313
|
+
const registry = await this.registry();
|
|
314
|
+
const names = projectNameMap(registry);
|
|
315
|
+
const shards = await this.store.listShards();
|
|
316
|
+
const snapshots = await Promise.all(shards.map((shard) => this.snapshot(shard)));
|
|
317
|
+
const byType = {};
|
|
318
|
+
const byStatus = {};
|
|
319
|
+
const byHarness = {};
|
|
320
|
+
const totals = {
|
|
321
|
+
records: 0,
|
|
322
|
+
active: 0,
|
|
323
|
+
superseded: 0,
|
|
324
|
+
archived: 0,
|
|
325
|
+
pinned: 0,
|
|
326
|
+
bytes: 0,
|
|
327
|
+
tokens: 0,
|
|
328
|
+
shards: snapshots.length,
|
|
329
|
+
projects: 0,
|
|
330
|
+
trash: 0,
|
|
331
|
+
digests: 0,
|
|
332
|
+
};
|
|
333
|
+
const fileErrors = [];
|
|
334
|
+
const shardRows = [];
|
|
335
|
+
for (const snapshot of snapshots) {
|
|
336
|
+
const shardTotal = {
|
|
337
|
+
name: snapshot.shard.name,
|
|
338
|
+
scope: snapshot.shard.scope,
|
|
339
|
+
project: snapshot.shard.project ?? null,
|
|
340
|
+
projectName: snapshot.shard.scope === "project" && snapshot.shard.project
|
|
341
|
+
? names.get(snapshot.shard.project) ?? fallbackProjectName(snapshot.shard.project)
|
|
342
|
+
: null,
|
|
343
|
+
label: this.labelOf(snapshot.shard, names),
|
|
344
|
+
total: snapshot.records.length,
|
|
345
|
+
active: 0,
|
|
346
|
+
superseded: 0,
|
|
347
|
+
archived: 0,
|
|
348
|
+
pinned: 0,
|
|
349
|
+
bytes: 0,
|
|
350
|
+
tokens: 0,
|
|
351
|
+
indexBytes: snapshot.indexBytes,
|
|
352
|
+
lastUpdated: null,
|
|
353
|
+
};
|
|
354
|
+
for (const record of snapshot.records) {
|
|
355
|
+
const bytes = snapshot.sizes.get(record.id) ?? 0;
|
|
356
|
+
const tokens = estimateTokens(record.content);
|
|
357
|
+
shardTotal.bytes += bytes;
|
|
358
|
+
shardTotal.tokens += tokens;
|
|
359
|
+
if (record.status === "active")
|
|
360
|
+
shardTotal.active += 1;
|
|
361
|
+
else if (record.status === "superseded")
|
|
362
|
+
shardTotal.superseded += 1;
|
|
363
|
+
else if (record.status === "archived")
|
|
364
|
+
shardTotal.archived += 1;
|
|
365
|
+
if (record.pinned && record.status === "active")
|
|
366
|
+
shardTotal.pinned += 1;
|
|
367
|
+
if (shardTotal.lastUpdated === null || record.updated > shardTotal.lastUpdated)
|
|
368
|
+
shardTotal.lastUpdated = record.updated;
|
|
369
|
+
bump(byType, record.type);
|
|
370
|
+
bump(byStatus, record.status);
|
|
371
|
+
bump(byHarness, record.source?.harness ?? "unknown");
|
|
372
|
+
totals.records += 1;
|
|
373
|
+
totals.bytes += bytes;
|
|
374
|
+
totals.tokens += tokens;
|
|
375
|
+
if (record.status === "active")
|
|
376
|
+
totals.active += 1;
|
|
377
|
+
else if (record.status === "superseded")
|
|
378
|
+
totals.superseded += 1;
|
|
379
|
+
else if (record.status === "archived")
|
|
380
|
+
totals.archived += 1;
|
|
381
|
+
if (record.pinned && record.status === "active")
|
|
382
|
+
totals.pinned += 1;
|
|
383
|
+
}
|
|
384
|
+
shardRows.push(shardTotal);
|
|
385
|
+
fileErrors.push(...snapshot.errors.map((error) => ({ ...error, file: `${snapshot.shard.name}/${error.file}` })));
|
|
386
|
+
}
|
|
387
|
+
shardRows.sort((a, b) => (a.scope === b.scope ? a.name.localeCompare(b.name) : a.scope === "user" ? -1 : 1));
|
|
388
|
+
const projects = await this.projects();
|
|
389
|
+
totals.projects = projects.length;
|
|
390
|
+
const index = await this.indexStatus(snapshots);
|
|
391
|
+
const context = this.renderContext({ cwd: options.cwd }, names);
|
|
392
|
+
const issues = [];
|
|
393
|
+
if (shards.length === 0)
|
|
394
|
+
issues.push({ level: "warn", code: "empty_home", message: `no shards under ${this.home}` });
|
|
395
|
+
if (index.stale) {
|
|
396
|
+
issues.push({
|
|
397
|
+
level: "warn",
|
|
398
|
+
code: "index_stale",
|
|
399
|
+
message: `index manifest seq ${index.manifest?.seq ?? "?"} is behind store seq ${index.seq}`,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
for (const file of index.files) {
|
|
403
|
+
if (file.records > 0 && !file.exists) {
|
|
404
|
+
issues.push({ level: "warn", code: "index_missing", message: `shard ${file.shard} has records but no index file` });
|
|
405
|
+
}
|
|
406
|
+
if (file.exists && file.records === 0) {
|
|
407
|
+
issues.push({ level: "warn", code: "index_orphan", message: `index/${basename(file.path)} has no records on disk` });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
for (const project of projects) {
|
|
411
|
+
if (!project.registered) {
|
|
412
|
+
issues.push({ level: "warn", code: "project_unregistered", message: `shard ${project.id} is not registered in projects.json` });
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const temporal = snapshots.flatMap((snapshot) => snapshot.records.filter((record) => record.status === "active" && record.validTo !== null && record.validTo <= this.clock()));
|
|
416
|
+
if (temporal.length > 0) {
|
|
417
|
+
issues.push({
|
|
418
|
+
level: "warn",
|
|
419
|
+
code: "temporal_conflict",
|
|
420
|
+
message: `${temporal.length} active record(s) carry a validTo timestamp in the past`,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (context.atBudget) {
|
|
424
|
+
issues.push({
|
|
425
|
+
level: "warn",
|
|
426
|
+
code: "injection_at_budget",
|
|
427
|
+
message: `injected block reaches the ${context.budgetTokens}-token budget; entries were dropped`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
for (const error of fileErrors) {
|
|
431
|
+
issues.push({ level: "error", code: "file_unreadable", message: `${error.file}: ${error.message}` });
|
|
432
|
+
}
|
|
433
|
+
return {
|
|
434
|
+
version: 1,
|
|
435
|
+
home: this.home,
|
|
436
|
+
generatedAt: this.clock(),
|
|
437
|
+
project: context.projectId,
|
|
438
|
+
projectName: context.projectName,
|
|
439
|
+
totals: {
|
|
440
|
+
...totals,
|
|
441
|
+
trash: await this.countFiles(join(this.home, "trash")),
|
|
442
|
+
digests: await this.countDigests(),
|
|
443
|
+
},
|
|
444
|
+
byType,
|
|
445
|
+
byStatus,
|
|
446
|
+
byHarness,
|
|
447
|
+
budget: { injectTokens: DEFAULT_BUDGET.injectTokens, hotEntries: DEFAULT_BUDGET.hotEntries, maxPinned: DEFAULT_BUDGET.maxPinned },
|
|
448
|
+
context,
|
|
449
|
+
shards: shardRows,
|
|
450
|
+
projects,
|
|
451
|
+
index,
|
|
452
|
+
issues,
|
|
453
|
+
fileErrors,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
async indexStatus(snapshots) {
|
|
457
|
+
const dir = join(this.home, "index");
|
|
458
|
+
const manifestPath = join(dir, "manifest.json");
|
|
459
|
+
const rawManifest = await readJson(manifestPath, null);
|
|
460
|
+
let manifestBytes = 0;
|
|
461
|
+
try {
|
|
462
|
+
manifestBytes = (await fs.stat(manifestPath)).size;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
manifestBytes = 0;
|
|
466
|
+
}
|
|
467
|
+
const manifestDocs = new Map();
|
|
468
|
+
for (const entry of rawManifest?.shards ?? []) {
|
|
469
|
+
if (typeof entry?.name === "string" && typeof entry.docs === "number")
|
|
470
|
+
manifestDocs.set(entry.name, entry.docs);
|
|
471
|
+
}
|
|
472
|
+
const recordCounts = new Map();
|
|
473
|
+
for (const snapshot of snapshots)
|
|
474
|
+
recordCounts.set(shardKey(snapshot.shard), snapshot.records.length);
|
|
475
|
+
let indexFiles = [];
|
|
476
|
+
try {
|
|
477
|
+
indexFiles = (await fs.readdir(dir)).filter((file) => file.endsWith(".json") && file !== "manifest.json").sort();
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
indexFiles = [];
|
|
481
|
+
}
|
|
482
|
+
const files = [];
|
|
483
|
+
for (const file of indexFiles) {
|
|
484
|
+
const path = join(dir, file);
|
|
485
|
+
const shard = basename(file, ".json");
|
|
486
|
+
let bytes = 0;
|
|
487
|
+
try {
|
|
488
|
+
bytes = (await fs.stat(path)).size;
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
bytes = 0;
|
|
492
|
+
}
|
|
493
|
+
files.push({
|
|
494
|
+
shard,
|
|
495
|
+
path: posix(relative(this.home, path)),
|
|
496
|
+
bytes,
|
|
497
|
+
exists: true,
|
|
498
|
+
docs: manifestDocs.get(shard) ?? null,
|
|
499
|
+
records: recordCounts.get(shard) ?? 0,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
// Shards with records but no index file are only visible from the store side.
|
|
503
|
+
for (const [shard, records] of recordCounts) {
|
|
504
|
+
if (records === 0 || files.some((file) => file.shard === shard))
|
|
505
|
+
continue;
|
|
506
|
+
files.push({ shard, path: posix(relative(this.home, join(dir, `${shard}.json`))), bytes: 0, exists: false, docs: manifestDocs.get(shard) ?? null, records });
|
|
507
|
+
}
|
|
508
|
+
files.sort((a, b) => a.shard.localeCompare(b.shard));
|
|
509
|
+
const seq = await this.store.getSeq();
|
|
510
|
+
const manifestSeq = typeof rawManifest?.seq === "number" ? rawManifest.seq : null;
|
|
511
|
+
const manifest = rawManifest && manifestSeq !== null
|
|
512
|
+
? {
|
|
513
|
+
seq: manifestSeq,
|
|
514
|
+
builtAt: typeof rawManifest.builtAt === "number" ? rawManifest.builtAt : 0,
|
|
515
|
+
bytes: manifestBytes,
|
|
516
|
+
shards: [...manifestDocs.entries()].map(([name, docs]) => ({ name, docs })),
|
|
517
|
+
}
|
|
518
|
+
: null;
|
|
519
|
+
return { manifest, seq, stale: manifest !== null && manifest.seq !== seq, files };
|
|
520
|
+
}
|
|
521
|
+
async countFiles(dir) {
|
|
522
|
+
try {
|
|
523
|
+
return (await fs.readdir(dir)).filter((file) => file.endsWith(".md")).length;
|
|
524
|
+
}
|
|
525
|
+
catch {
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
async countDigests() {
|
|
530
|
+
try {
|
|
531
|
+
const dirs = await fs.readdir(join(this.home, "digests"), { withFileTypes: true });
|
|
532
|
+
let total = 0;
|
|
533
|
+
for (const dir of dirs) {
|
|
534
|
+
if (!dir.isDirectory())
|
|
535
|
+
continue;
|
|
536
|
+
total += await this.countFiles(join(this.home, "digests", dir.name));
|
|
537
|
+
}
|
|
538
|
+
return total;
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
/** Memories in one shard (or all shards), newest-first per the panel order. */
|
|
545
|
+
async list(options = {}) {
|
|
546
|
+
const registry = await this.registry();
|
|
547
|
+
const names = projectNameMap(registry);
|
|
548
|
+
const requested = options.shard ?? "all";
|
|
549
|
+
const shards = await this.shardsFor(requested);
|
|
550
|
+
const snapshots = await Promise.all(shards.map((shard) => this.snapshot(shard)));
|
|
551
|
+
const rows = [];
|
|
552
|
+
for (const snapshot of snapshots) {
|
|
553
|
+
for (const record of snapshot.records) {
|
|
554
|
+
rows.push(this.row(record, snapshot.shard, names, snapshot.sizes.get(record.id) ?? 0));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
rows.sort(sortRows);
|
|
558
|
+
const filtered = options.includeInactive === false ? rows.filter((row) => row.status === "active") : rows;
|
|
559
|
+
const label = requested === "all" || shards.length !== 1 ? "all" : shards[0] ? this.labelOf(shards[0], names) : requested;
|
|
560
|
+
return { shard: requested, label, total: filtered.length, rows: filtered, generatedAt: this.clock() };
|
|
561
|
+
}
|
|
562
|
+
/** One memory with body, frontmatter, raw text, backlinks and optional history. */
|
|
563
|
+
async read(options) {
|
|
564
|
+
const registry = await this.registry();
|
|
565
|
+
const names = projectNameMap(registry);
|
|
566
|
+
const shards = await this.store.listShards();
|
|
567
|
+
const snapshots = await Promise.all(shards.map((shard) => this.snapshot(shard)));
|
|
568
|
+
let found = null;
|
|
569
|
+
for (const snapshot of snapshots) {
|
|
570
|
+
const record = snapshot.records.find((candidate) => candidate.id === options.id);
|
|
571
|
+
if (record) {
|
|
572
|
+
found = { snapshot, record };
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (!found)
|
|
577
|
+
return null;
|
|
578
|
+
const { snapshot, record } = found;
|
|
579
|
+
const filePath = this.store.recordPath(snapshot.shard, record.id);
|
|
580
|
+
const raw = await readTextIfExists(filePath);
|
|
581
|
+
const parsed = raw === null ? null : parseFrontmatter(raw);
|
|
582
|
+
const bytes = raw === null ? snapshot.sizes.get(record.id) ?? 0 : Buffer.byteLength(raw, "utf8");
|
|
583
|
+
const rows = this.row(record, snapshot.shard, names, bytes);
|
|
584
|
+
const memory = {
|
|
585
|
+
...rows,
|
|
586
|
+
content: record.content,
|
|
587
|
+
frontmatter: (parsed?.data ?? {}),
|
|
588
|
+
raw: raw ?? "",
|
|
589
|
+
filePath: posix(relative(this.home, filePath)),
|
|
590
|
+
backlinks: [
|
|
591
|
+
...new Set(snapshots.flatMap((candidate) => candidate.records.filter((other) => other.id !== record.id && other.links.includes(record.id)).map((other) => other.id))),
|
|
592
|
+
].sort(),
|
|
593
|
+
};
|
|
594
|
+
if (options.history) {
|
|
595
|
+
const all = snapshots.flatMap((candidate) => candidate.records.map((item) => ({ record: item, size: candidate.sizes.get(item.id) ?? 0, shard: candidate.shard })));
|
|
596
|
+
const byId = new Map(all.map((item) => [item.record.id, item]));
|
|
597
|
+
const chain = [];
|
|
598
|
+
const seen = new Set([record.id]);
|
|
599
|
+
let cursor = record;
|
|
600
|
+
while (cursor?.supersededBy) {
|
|
601
|
+
const next = byId.get(cursor.supersededBy);
|
|
602
|
+
if (!next || seen.has(next.record.id))
|
|
603
|
+
break;
|
|
604
|
+
seen.add(next.record.id);
|
|
605
|
+
chain.push(this.row(next.record, next.shard, names, next.size));
|
|
606
|
+
cursor = next.record;
|
|
607
|
+
}
|
|
608
|
+
memory.history = chain;
|
|
609
|
+
}
|
|
610
|
+
return memory;
|
|
611
|
+
}
|
|
612
|
+
/** Replay the injected hot-index block for a working directory. */
|
|
613
|
+
async context(options = {}) {
|
|
614
|
+
const registry = await this.registry();
|
|
615
|
+
return this.renderContext(options, projectNameMap(registry));
|
|
616
|
+
}
|
|
617
|
+
/** Verbatim `MEMORY.md` shard index text (what humans and models skim). */
|
|
618
|
+
async indexText(options = {}) {
|
|
619
|
+
const registry = await this.registry();
|
|
620
|
+
const names = projectNameMap(registry);
|
|
621
|
+
const requested = options.shard ?? "all";
|
|
622
|
+
const shards = await this.shardsFor(requested);
|
|
623
|
+
const snapshots = await Promise.all(shards.map((shard) => this.snapshot(shard)));
|
|
624
|
+
const parts = snapshots.map((snapshot) => ({
|
|
625
|
+
shard: snapshot.shard.name,
|
|
626
|
+
label: this.labelOf(snapshot.shard, names),
|
|
627
|
+
path: posix(relative(this.home, this.store.indexFile(snapshot.shard))),
|
|
628
|
+
text: snapshot.indexText ?? "",
|
|
629
|
+
bytes: snapshot.indexBytes,
|
|
630
|
+
}));
|
|
631
|
+
const text = parts.length === 0
|
|
632
|
+
? ""
|
|
633
|
+
: parts.length === 1
|
|
634
|
+
? parts[0]?.text ?? ""
|
|
635
|
+
: parts.map((part) => `<!-- harnmem shard: ${part.shard} -->\n\n${part.text}`).join("\n\n");
|
|
636
|
+
return { text, bytes: Buffer.byteLength(text, "utf8"), parts, generatedAt: this.clock() };
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** Convenience wrapper around {@link MemoryInspector.open}. */
|
|
640
|
+
export async function openInspector(options = {}) {
|
|
641
|
+
return MemoryInspector.open(options);
|
|
642
|
+
}
|
|
643
|
+
//# sourceMappingURL=inspect.js.map
|