pi-memoria 0.1.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/src/util.ts ADDED
@@ -0,0 +1,138 @@
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
+ await (typeof data === "string" ? writeFile(tmp, data, "utf8") : writeFile(tmp, data));
96
+ try {
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
+ const target = join(trashDir, `${stamp}__${base}`);
136
+ await rename(path, target);
137
+ return target;
138
+ }