pi-memoria 0.2.0 → 0.3.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/AGENTS.md +110 -0
- package/README.md +126 -166
- package/index.ts +301 -204
- package/package.json +29 -69
- package/src/aliases.ts +104 -0
- package/src/config.ts +22 -184
- package/src/database.ts +301 -0
- package/src/errors.ts +55 -0
- package/src/files.ts +29 -0
- package/src/hot-memory.ts +231 -0
- package/src/results.ts +264 -0
- package/src/sessions.ts +246 -843
- package/src/text.ts +48 -0
- package/skills/memoria/SKILL.md +0 -77
- package/skills/memoria-sessions/SKILL.md +0 -69
- package/src/commands.ts +0 -398
- package/src/frontmatter.ts +0 -318
- package/src/index-engine.ts +0 -1071
- package/src/recall.ts +0 -211
- package/src/rerank.ts +0 -85
- package/src/runtime.ts +0 -1469
- package/src/search.ts +0 -499
- package/src/similarity.ts +0 -374
- package/src/store.ts +0 -1724
- package/src/synonyms.ts +0 -105
- package/src/timeexpr.ts +0 -145
- package/src/tokenize.ts +0 -295
- package/src/tools.ts +0 -813
- package/src/types.ts +0 -541
- package/src/usage.ts +0 -188
- package/src/util.ts +0 -141
package/src/usage.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Retrieval and write statistics, kept in `.index/usage.json`.
|
|
3
|
-
*
|
|
4
|
-
* This is derived data: deleting the file loses promotion and staleness hints
|
|
5
|
-
* and nothing else. The search path must never do disk I/O, so hits are counted
|
|
6
|
-
* in memory and flushed on a debounce; the runtime also refreshes a note's
|
|
7
|
-
* `last_used` frontmatter at most once per `lastUsedWriteIntervalMs`.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import { INDEX_DIR, USAGE_FILE } from "./config.ts";
|
|
12
|
-
import { atomicWriteFile, readFileOrUndefined } from "./util.ts";
|
|
13
|
-
import type { UsageEntry, UsageFile } from "./types.ts";
|
|
14
|
-
|
|
15
|
-
export const USAGE_VERSION = 1;
|
|
16
|
-
|
|
17
|
-
/** Upper bound on tracked notes; the least recently used are dropped first. */
|
|
18
|
-
export const MAX_USAGE_ENTRIES = 50_000;
|
|
19
|
-
|
|
20
|
-
function emptyEntry(): UsageEntry {
|
|
21
|
-
return { hits: 0, writes: 0, lastUsed: 0 };
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function coerceEntry(raw: unknown): UsageEntry | undefined {
|
|
25
|
-
if (!raw || typeof raw !== "object") return undefined;
|
|
26
|
-
const input = raw as Record<string, unknown>;
|
|
27
|
-
const number = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0);
|
|
28
|
-
const entry = { hits: number(input.hits), writes: number(input.writes), lastUsed: number(input.lastUsed) };
|
|
29
|
-
if (entry.hits === 0 && entry.writes === 0 && entry.lastUsed === 0) return undefined;
|
|
30
|
-
return entry;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** In-memory usage table with an explicit, debounced flush. */
|
|
34
|
-
export class UsageStore {
|
|
35
|
-
private docs = new Map<string, UsageEntry>();
|
|
36
|
-
private changed = false;
|
|
37
|
-
private saving?: Promise<void>;
|
|
38
|
-
private lastSessionAt = 0;
|
|
39
|
-
|
|
40
|
-
constructor(root: string) {
|
|
41
|
-
this.root = root;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
private readonly root: string;
|
|
45
|
-
|
|
46
|
-
/** Load `<root>/.index/usage.json`; a missing or corrupt file starts empty. */
|
|
47
|
-
static async load(root: string): Promise<UsageStore> {
|
|
48
|
-
const store = new UsageStore(root);
|
|
49
|
-
const raw = await readFileOrUndefined(join(root, INDEX_DIR, USAGE_FILE));
|
|
50
|
-
if (!raw) return store;
|
|
51
|
-
try {
|
|
52
|
-
const parsed = JSON.parse(raw) as UsageFile;
|
|
53
|
-
if (!parsed || typeof parsed !== "object" || !parsed.docs || typeof parsed.docs !== "object") return store;
|
|
54
|
-
if (typeof parsed.lastSessionAt === "number" && Number.isFinite(parsed.lastSessionAt)) store.lastSessionAt = parsed.lastSessionAt;
|
|
55
|
-
for (const [id, value] of Object.entries(parsed.docs)) {
|
|
56
|
-
const entry = coerceEntry(value);
|
|
57
|
-
if (entry) store.docs.set(id, entry);
|
|
58
|
-
}
|
|
59
|
-
} catch {
|
|
60
|
-
// Derived data: a corrupt file is simply ignored.
|
|
61
|
-
}
|
|
62
|
-
return store;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
get(id: string): UsageEntry | undefined {
|
|
66
|
-
return this.docs.get(id);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Count one retrieval (`hit`) or one fact write (`write`). */
|
|
70
|
-
record(id: string, kind: "hit" | "write", at = Date.now()): void {
|
|
71
|
-
if (!id) return;
|
|
72
|
-
let entry = this.docs.get(id);
|
|
73
|
-
if (!entry) {
|
|
74
|
-
entry = emptyEntry();
|
|
75
|
-
this.docs.set(id, entry);
|
|
76
|
-
}
|
|
77
|
-
if (kind === "hit") entry.hits += 1;
|
|
78
|
-
else entry.writes += 1;
|
|
79
|
-
if (at > entry.lastUsed) entry.lastUsed = at;
|
|
80
|
-
this.changed = true;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Note that a note was touched without counting a read or a write. */
|
|
84
|
-
markUsed(id: string, at: number): void {
|
|
85
|
-
const entry = this.docs.get(id);
|
|
86
|
-
if (entry) {
|
|
87
|
-
if (at > entry.lastUsed) {
|
|
88
|
-
entry.lastUsed = at;
|
|
89
|
-
this.changed = true;
|
|
90
|
-
}
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
this.docs.set(id, { hits: 0, writes: 0, lastUsed: at });
|
|
94
|
-
this.changed = true;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
entries(): Array<[string, UsageEntry]> {
|
|
98
|
-
return [...this.docs.entries()];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
get size(): number {
|
|
102
|
-
return this.docs.size;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
get dirty(): boolean {
|
|
106
|
-
return this.changed;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Drop entries whose note no longer exists, then cap the table size. */
|
|
110
|
-
prune(validIds: Set<string>): number {
|
|
111
|
-
let removed = 0;
|
|
112
|
-
for (const id of [...this.docs.keys()]) {
|
|
113
|
-
if (!validIds.has(id)) {
|
|
114
|
-
this.docs.delete(id);
|
|
115
|
-
removed += 1;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (this.docs.size > MAX_USAGE_ENTRIES) {
|
|
119
|
-
const byAge = [...this.docs.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed);
|
|
120
|
-
for (const [id] of byAge.slice(0, this.docs.size - MAX_USAGE_ENTRIES)) {
|
|
121
|
-
this.docs.delete(id);
|
|
122
|
-
removed += 1;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
if (removed > 0) this.changed = true;
|
|
126
|
-
return removed;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** Epoch ms of the previous session start (0 when unknown). */
|
|
130
|
-
sessionStartedAt(): number {
|
|
131
|
-
return this.lastSessionAt;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Record this session's start so the next one can summarise what changed. */
|
|
135
|
-
markSessionStart(at = Date.now()): void {
|
|
136
|
-
this.lastSessionAt = at;
|
|
137
|
-
this.changed = true;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async save(): Promise<void> {
|
|
141
|
-
if (this.saving) {
|
|
142
|
-
await this.saving;
|
|
143
|
-
return this.save();
|
|
144
|
-
}
|
|
145
|
-
if (!this.changed) return;
|
|
146
|
-
this.changed = false;
|
|
147
|
-
const payload: UsageFile = { version: USAGE_VERSION, docs: Object.fromEntries(this.docs), lastSessionAt: this.lastSessionAt };
|
|
148
|
-
const saving = atomicWriteFile(join(this.root, INDEX_DIR, USAGE_FILE), JSON.stringify(payload));
|
|
149
|
-
this.saving = saving;
|
|
150
|
-
try {
|
|
151
|
-
await saving;
|
|
152
|
-
} catch (error) {
|
|
153
|
-
this.changed = true;
|
|
154
|
-
throw error;
|
|
155
|
-
} finally {
|
|
156
|
-
if (this.saving === saving) this.saving = undefined;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** Notes with no recorded use in `staleMs`, oldest first. */
|
|
161
|
-
stale(staleMs: number, now = Date.now(), minWrites = 1): Array<{ id: string; entry: UsageEntry; idleMs: number }> {
|
|
162
|
-
const out: Array<{ id: string; entry: UsageEntry; idleMs: number }> = [];
|
|
163
|
-
for (const [id, entry] of this.docs) {
|
|
164
|
-
if (entry.writes < minWrites && entry.hits === 0) continue;
|
|
165
|
-
const idleMs = now - (entry.lastUsed || 0);
|
|
166
|
-
if (idleMs >= staleMs) out.push({ id, entry, idleMs });
|
|
167
|
-
}
|
|
168
|
-
return out.sort((a, b) => b.idleMs - a.idleMs);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** Notes written often enough to be worth promoting into MEMORY.md. */
|
|
172
|
-
promotionCandidates(minWrites: number): Array<{ id: string; entry: UsageEntry }> {
|
|
173
|
-
const out: Array<{ id: string; entry: UsageEntry }> = [];
|
|
174
|
-
for (const [id, entry] of this.docs) if (entry.writes >= minWrites) out.push({ id, entry });
|
|
175
|
-
return out.sort((a, b) => b.entry.writes - a.entry.writes);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** Human-readable idle duration, e.g. "3 months" or "12 days". */
|
|
180
|
-
export function describeAge(ms: number): string {
|
|
181
|
-
const days = Math.floor(ms / 86_400_000);
|
|
182
|
-
if (days < 1) return "today";
|
|
183
|
-
if (days < 45) return `${days} day${days === 1 ? "" : "s"}`;
|
|
184
|
-
const months = Math.round(days / 30);
|
|
185
|
-
if (months < 18) return `${months} months`;
|
|
186
|
-
const years = Math.max(1, Math.round(days / 365));
|
|
187
|
-
return `${years} year${years === 1 ? "" : "s"}`;
|
|
188
|
-
}
|
package/src/util.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Small dependency-free helpers shared across memoria.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
-
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
7
|
-
import { dirname, join } from "node:path";
|
|
8
|
-
|
|
9
|
-
/** Generate a stable-ish memory id: `mem_<epochms>_<6 hex>`. */
|
|
10
|
-
export function newMemoryId(now = Date.now()): string {
|
|
11
|
-
return `mem_${now}_${randomBytes(3).toString("hex")}`;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** Short sha1 fingerprint of a string. */
|
|
15
|
-
export function shortHash(input: string): string {
|
|
16
|
-
return createHash("sha1").update(input).digest("hex").slice(0, 12);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Truncate to `max` characters without splitting a surrogate pair. */
|
|
20
|
-
export function truncateChars(input: string, max: number): string {
|
|
21
|
-
if (max <= 0) return "";
|
|
22
|
-
if (input.length <= max) return input;
|
|
23
|
-
let end = max;
|
|
24
|
-
const code = input.charCodeAt(end - 1);
|
|
25
|
-
if (code >= 0xd800 && code <= 0xdbff) end -= 1;
|
|
26
|
-
return input.slice(0, end);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Collapse runs of whitespace, trim, and optionally truncate. */
|
|
30
|
-
export function oneLine(input: string, max = Number.POSITIVE_INFINITY): string {
|
|
31
|
-
const flat = input.replace(/\s+/g, " ").trim();
|
|
32
|
-
return flat.length > max ? `${truncateChars(flat, Math.max(1, max - 1))}…` : flat;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Keyed async mutex. Serializes read-modify-write sequences on the same key
|
|
37
|
-
* (typically a store root) so concurrent tool calls cannot lose writes.
|
|
38
|
-
*/
|
|
39
|
-
export class KeyedMutex {
|
|
40
|
-
private tails = new Map<string, Promise<void>>();
|
|
41
|
-
|
|
42
|
-
async run<T>(key: string, fn: () => Promise<T> | T): Promise<T> {
|
|
43
|
-
const previous = this.tails.get(key) ?? Promise.resolve();
|
|
44
|
-
let release!: () => void;
|
|
45
|
-
const current = new Promise<void>((resolve) => {
|
|
46
|
-
release = resolve;
|
|
47
|
-
});
|
|
48
|
-
const tail = previous.catch(() => {}).then(() => current);
|
|
49
|
-
this.tails.set(key, tail);
|
|
50
|
-
await previous.catch(() => {});
|
|
51
|
-
try {
|
|
52
|
-
return await fn();
|
|
53
|
-
} finally {
|
|
54
|
-
release();
|
|
55
|
-
if (this.tails.get(key) === tail) this.tails.delete(key);
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Debounce a function; the trailing call wins. */
|
|
61
|
-
export function debounce<A extends unknown[]>(fn: (...args: A) => void, waitMs: number): ((...args: A) => void) & { flush: () => void; cancel: () => void } {
|
|
62
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
-
let lastArgs: A | undefined;
|
|
64
|
-
const flush = () => {
|
|
65
|
-
if (timer === undefined) return;
|
|
66
|
-
clearTimeout(timer);
|
|
67
|
-
timer = undefined;
|
|
68
|
-
const args = lastArgs;
|
|
69
|
-
lastArgs = undefined;
|
|
70
|
-
if (args) fn(...args);
|
|
71
|
-
};
|
|
72
|
-
const cancel = () => {
|
|
73
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
74
|
-
timer = undefined;
|
|
75
|
-
lastArgs = undefined;
|
|
76
|
-
};
|
|
77
|
-
const wrapped = (...args: A) => {
|
|
78
|
-
lastArgs = args;
|
|
79
|
-
if (timer !== undefined) clearTimeout(timer);
|
|
80
|
-
timer = setTimeout(() => {
|
|
81
|
-
timer = undefined;
|
|
82
|
-
const callArgs = lastArgs;
|
|
83
|
-
lastArgs = undefined;
|
|
84
|
-
if (callArgs) fn(...callArgs);
|
|
85
|
-
}, waitMs);
|
|
86
|
-
timer.unref?.();
|
|
87
|
-
};
|
|
88
|
-
return Object.assign(wrapped, { flush, cancel });
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Write a file atomically (temp file + rename), creating parent dirs. */
|
|
92
|
-
export async function atomicWriteFile(path: string, data: string | Uint8Array): Promise<void> {
|
|
93
|
-
await mkdir(dirname(path), { recursive: true });
|
|
94
|
-
const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
95
|
-
try {
|
|
96
|
-
await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
|
|
97
|
-
await rename(tmp, path);
|
|
98
|
-
} catch (error) {
|
|
99
|
-
await rm(tmp, { force: true }).catch(() => {});
|
|
100
|
-
throw error;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Read a file as utf8, returning undefined when it does not exist. */
|
|
105
|
-
export async function readFileOrUndefined(path: string): Promise<string | undefined> {
|
|
106
|
-
try {
|
|
107
|
-
return await readFile(path, "utf8");
|
|
108
|
-
} catch (error) {
|
|
109
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
110
|
-
throw error;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** Read a file as bytes, returning undefined when it does not exist. */
|
|
115
|
-
export async function readFileBufferOrUndefined(path: string): Promise<Buffer | undefined> {
|
|
116
|
-
try {
|
|
117
|
-
return await readFile(path);
|
|
118
|
-
} catch (error) {
|
|
119
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
120
|
-
throw error;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Normalize a path for comparisons: forward slashes, no trailing slash. */
|
|
125
|
-
export function normalizePath(input: string): string {
|
|
126
|
-
const slashed = input.replace(/\\/g, "/");
|
|
127
|
-
return slashed.length > 1 ? slashed.replace(/\/+$/, "") : slashed;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Move to trash (soft delete) instead of hard deleting. */
|
|
131
|
-
export async function moveToTrash(path: string, trashDir: string): Promise<string> {
|
|
132
|
-
await mkdir(trashDir, { recursive: true });
|
|
133
|
-
const base = path.split("/").pop() ?? "memory.md";
|
|
134
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
135
|
-
// Different categories can contain the same basename and be removed within
|
|
136
|
-
// one millisecond. A random suffix prevents rename() from replacing the
|
|
137
|
-
// earlier trashed file on POSIX filesystems.
|
|
138
|
-
const target = join(trashDir, `${stamp}_${randomBytes(8).toString("hex")}__${base}`);
|
|
139
|
-
await rename(path, target);
|
|
140
|
-
return target;
|
|
141
|
-
}
|