@qltk/pi-mnemo 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/CHANGELOG.md +25 -0
- package/README.md +93 -0
- package/index.ts +88 -0
- package/package.json +45 -0
- package/prompts/dream.md +59 -0
- package/prompts/extract.md +76 -0
- package/prompts/inject.md +47 -0
- package/src/atomic-writer.ts +22 -0
- package/src/citation-parser.ts +11 -0
- package/src/config.ts +90 -0
- package/src/dream-lock.ts +67 -0
- package/src/dream-safety.ts +181 -0
- package/src/extract-success.ts +28 -0
- package/src/frontmatter.ts +74 -0
- package/src/io.ts +25 -0
- package/src/log.ts +21 -0
- package/src/memory-index.ts +114 -0
- package/src/orchestrator.ts +275 -0
- package/src/pi-session-scan.ts +106 -0
- package/src/prompt.ts +28 -0
- package/src/recall.ts +32 -0
- package/src/sanitizer.ts +44 -0
- package/src/slug.ts +12 -0
- package/src/state-store.ts +39 -0
- package/src/types.ts +80 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// dream-safety.ts — post-dream validation + snapshot/rollback safety net.
|
|
2
|
+
// 0.5.8: validation 语义修正(空 project scope = 合法,0.5.5 兜底 slug 常态化)
|
|
3
|
+
// + .dream-backup 文件级快照回滚(替代从未实现的 git 快照方案——
|
|
4
|
+
// 旧失败日志文案谎称 git 工具不可用,实际代码从无 git 调用,已删)。
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
statSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
copyFileSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
rmdirSync,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { join, dirname } from "node:path";
|
|
18
|
+
|
|
19
|
+
/** 快照目录名(memoryRoot 下)。 */
|
|
20
|
+
export const BACKUP_DIRNAME = ".dream-backup";
|
|
21
|
+
|
|
22
|
+
/** 不进快照/不回滚的文件(dream_log 是审计 trail,FAILED 时保留失败现场)。 */
|
|
23
|
+
const EXCLUDE_FILES = new Set(["dream_log.md"]);
|
|
24
|
+
|
|
25
|
+
export interface ValidationResult {
|
|
26
|
+
ok: boolean;
|
|
27
|
+
/** 失败时的真实原因(写 log + dream_log)。 */
|
|
28
|
+
reason?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validation 语义(0.5.8):
|
|
33
|
+
* - global MEMORY.md 必须存在且索引非空(global 是主 scope)
|
|
34
|
+
* - project MEMORY.md 不存在 = 空 project scope,合法状态(不判失败)
|
|
35
|
+
* - project MEMORY.md 存在则索引不得为空
|
|
36
|
+
*/
|
|
37
|
+
export function validateArtifacts(
|
|
38
|
+
globalDirPath: string,
|
|
39
|
+
projectDirPath: string,
|
|
40
|
+
): ValidationResult {
|
|
41
|
+
const idxLines = (f: string): number => {
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(f, "utf-8")
|
|
44
|
+
.split("\n")
|
|
45
|
+
.filter((l) => l.trim() && !l.startsWith("#") && !l.startsWith("<!--")).length;
|
|
46
|
+
} catch {
|
|
47
|
+
return -1;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const gMem = join(globalDirPath, "MEMORY.md");
|
|
51
|
+
if (!existsSync(gMem)) return { ok: false, reason: "global MEMORY.md missing" };
|
|
52
|
+
if (idxLines(gMem) <= 0) {
|
|
53
|
+
return { ok: false, reason: "global MEMORY.md exists but index has no topic lines" };
|
|
54
|
+
}
|
|
55
|
+
const pMem = join(projectDirPath, "MEMORY.md");
|
|
56
|
+
if (existsSync(pMem) && idxLines(pMem) <= 0) {
|
|
57
|
+
return { ok: false, reason: `project MEMORY.md (${projectDirPath}) exists but index has no topic lines` };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 收集 memoryRoot 下所有可快照 .md 的相对路径(排除 .dream-backup、dream_log.md)。 */
|
|
63
|
+
function listSnapshottable(memoryRoot: string): string[] {
|
|
64
|
+
const out: string[] = [];
|
|
65
|
+
const walk = (dir: string, prefix: string) => {
|
|
66
|
+
let names: string[];
|
|
67
|
+
try {
|
|
68
|
+
names = readdirSync(dir);
|
|
69
|
+
} catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
for (const n of names) {
|
|
73
|
+
if (n === BACKUP_DIRNAME) continue;
|
|
74
|
+
const full = join(dir, n);
|
|
75
|
+
let st;
|
|
76
|
+
try {
|
|
77
|
+
st = statSync(full);
|
|
78
|
+
} catch {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (st.isDirectory()) {
|
|
82
|
+
walk(full, prefix ? `${prefix}/${n}` : n);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!n.endsWith(".md") || EXCLUDE_FILES.has(n)) continue;
|
|
86
|
+
out.push(prefix ? `${prefix}/${n}` : n);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(memoryRoot, "");
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* dream 前快照全部 memory .md(global + 所有 projects)。
|
|
95
|
+
* 先清残留旧快照(上个 dream crash 未清理的)。
|
|
96
|
+
* 返回快照文件数。
|
|
97
|
+
*/
|
|
98
|
+
export function snapshotMemory(memoryRoot: string, backupDir: string): number {
|
|
99
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
100
|
+
const files = listSnapshottable(memoryRoot);
|
|
101
|
+
mkdirSync(backupDir, { recursive: true });
|
|
102
|
+
for (const rel of files) {
|
|
103
|
+
const dest = join(backupDir, rel);
|
|
104
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
105
|
+
copyFileSync(join(memoryRoot, rel), dest);
|
|
106
|
+
}
|
|
107
|
+
return files.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* validation FAILED 时回滚:删除当前全部可快照 .md(含 dream 新建的),
|
|
112
|
+
* 从快照拷回,清理空 project 目录(防假 slug 进 dream backlog)。
|
|
113
|
+
* dream_log.md 不动(保留失败现场审计)。
|
|
114
|
+
* 返回 false = 无快照可回滚。
|
|
115
|
+
*/
|
|
116
|
+
export function restoreMemory(memoryRoot: string, backupDir: string): boolean {
|
|
117
|
+
if (!existsSync(backupDir)) return false;
|
|
118
|
+
// 1. 删现有(同规则收集,dream 新建的 .md 也会被列出删除)
|
|
119
|
+
for (const rel of listSnapshottable(memoryRoot)) {
|
|
120
|
+
try {
|
|
121
|
+
unlinkSync(join(memoryRoot, rel));
|
|
122
|
+
} catch {
|
|
123
|
+
// best-effort
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// 2. 快照拷回
|
|
127
|
+
let restored = 0;
|
|
128
|
+
const copyBack = (src: string, dest: string) => {
|
|
129
|
+
let names: string[];
|
|
130
|
+
try {
|
|
131
|
+
names = readdirSync(src);
|
|
132
|
+
} catch {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
for (const n of names) {
|
|
136
|
+
const s = join(src, n);
|
|
137
|
+
const d = join(dest, n);
|
|
138
|
+
let st;
|
|
139
|
+
try {
|
|
140
|
+
st = statSync(s);
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (st.isDirectory()) {
|
|
145
|
+
mkdirSync(d, { recursive: true });
|
|
146
|
+
copyBack(s, d);
|
|
147
|
+
} else {
|
|
148
|
+
mkdirSync(dirname(d), { recursive: true });
|
|
149
|
+
copyFileSync(s, d);
|
|
150
|
+
restored++;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
try {
|
|
155
|
+
copyBack(backupDir, memoryRoot);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
// 3. 清空 project 目录
|
|
160
|
+
try {
|
|
161
|
+
const projRoot = join(memoryRoot, "projects");
|
|
162
|
+
if (existsSync(projRoot)) {
|
|
163
|
+
for (const n of readdirSync(projRoot)) {
|
|
164
|
+
const p = join(projRoot, n);
|
|
165
|
+
try {
|
|
166
|
+
if (statSync(p).isDirectory() && readdirSync(p).length === 0) rmdirSync(p);
|
|
167
|
+
} catch {
|
|
168
|
+
// best-effort
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// best-effort
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** validation OK 后丢弃快照。 */
|
|
179
|
+
export function discardBackup(backupDir: string): void {
|
|
180
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// extract-success.ts — judge whether a fork session's messages indicate extract success.
|
|
2
|
+
// 0.5.4: extract onComplete 用此判据决定是否推进 cursor(成功推进,失败保留重试)。
|
|
3
|
+
//
|
|
4
|
+
// 成功 = 最后一条 assistant 有有效 text(含"判断无可记"的正常返回)且无 error part。
|
|
5
|
+
// 失败 = 无 assistant 回复(配额耗尽 session 无输出),或 assistant 含 error part(模型错误)。
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Judge extract success from fork session messages.
|
|
9
|
+
* Success = last assistant message has non-empty text and no error part.
|
|
10
|
+
* Failure = no assistant message, or last assistant has error part.
|
|
11
|
+
* Pure function, no side effects.
|
|
12
|
+
*/
|
|
13
|
+
export function isExtractSuccessful(msgs: any[]): boolean {
|
|
14
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
15
|
+
const role = msgs[i]?.info?.role || msgs[i]?.role;
|
|
16
|
+
if (role === "assistant") {
|
|
17
|
+
const parts = msgs[i]?.info?.parts || msgs[i]?.parts || [];
|
|
18
|
+
const hasText = parts.some(
|
|
19
|
+
(p: any) => p?.type === "text" && typeof p.text === "string" && p.text.trim().length > 0,
|
|
20
|
+
);
|
|
21
|
+
const hasError = parts.some(
|
|
22
|
+
(p: any) => p?.type === "error" || typeof p?.error !== "undefined",
|
|
23
|
+
);
|
|
24
|
+
return hasText && !hasError;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// frontmatter.ts — parse/bump frontmatter in topic files.
|
|
2
|
+
// Extracted from io.js for testability. Uses TopicFrontmatter type.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { atomicWrite } from "./atomic-writer.js";
|
|
6
|
+
import type { TopicFrontmatter } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** Regex to match YAML frontmatter block at start of file. */
|
|
9
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse YAML frontmatter from a topic file's raw content.
|
|
13
|
+
* Returns the frontmatter object + the body (content after frontmatter).
|
|
14
|
+
*/
|
|
15
|
+
export function parseFrontmatter(content: string): { frontmatter: TopicFrontmatter; body: string } | null {
|
|
16
|
+
const m = content.match(FRONTMATTER_RE);
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
const fmText = m[1];
|
|
19
|
+
const frontmatter: TopicFrontmatter = { name: "", type: "project" };
|
|
20
|
+
|
|
21
|
+
// Simple line-based YAML parser (avoids js-yaml dependency)
|
|
22
|
+
for (const line of fmText.split("\n")) {
|
|
23
|
+
const kv = line.match(/^(\w[\w_]*):\s*(.*)$/);
|
|
24
|
+
if (!kv) continue;
|
|
25
|
+
const [, key, val] = kv;
|
|
26
|
+
if (key === "name") frontmatter.name = val;
|
|
27
|
+
else if (key === "description") frontmatter.description = val;
|
|
28
|
+
else if (key === "type") frontmatter.type = val as TopicFrontmatter["type"];
|
|
29
|
+
else if (key === "deprecated_by") frontmatter.deprecated_by = val;
|
|
30
|
+
else if (key === "usage_count") frontmatter.usage_count = parseInt(val, 10);
|
|
31
|
+
else if (key === "last_used") frontmatter.last_used = parseInt(val, 10);
|
|
32
|
+
else (frontmatter as any)[key] = val;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { frontmatter, body: content.slice(m[0].length) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Bump usage_count + last_used in a topic file's frontmatter.
|
|
40
|
+
* 借鉴 codex citation/usage 闭环:被引用的 topic bump usage_count + last_used。
|
|
41
|
+
* Returns true if bumped, false if no frontmatter or read error.
|
|
42
|
+
*/
|
|
43
|
+
export function bumpUsage(filePath: string): boolean {
|
|
44
|
+
let content: string;
|
|
45
|
+
try {
|
|
46
|
+
content = readFileSync(filePath, "utf-8");
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const m = content.match(FRONTMATTER_RE);
|
|
52
|
+
if (!m) return false;
|
|
53
|
+
|
|
54
|
+
let fm = m[1];
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
|
|
57
|
+
const countMatch = fm.match(/^usage_count:\s*(\d+)/m);
|
|
58
|
+
const newCount = countMatch ? parseInt(countMatch[1], 10) + 1 : 1;
|
|
59
|
+
|
|
60
|
+
fm = countMatch
|
|
61
|
+
? fm.replace(/^usage_count:\s*\d+/m, `usage_count: ${newCount}`)
|
|
62
|
+
: `${fm.trimEnd()}\nusage_count: ${newCount}`;
|
|
63
|
+
|
|
64
|
+
fm = fm.match(/^last_used:/m)
|
|
65
|
+
? fm.replace(/^last_used:.*$/m, `last_used: ${now}`)
|
|
66
|
+
: `${fm.trimEnd()}\nlast_used: ${now}`;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
atomicWrite(filePath, `---\n${fm}\n---\n` + content.slice(m[0].length));
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
package/src/io.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// io.ts — memory file operations barrel.
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { CONFIG } from "./config.js";
|
|
6
|
+
import { log } from "./log.js";
|
|
7
|
+
|
|
8
|
+
// Scope helpers (need CONFIG)
|
|
9
|
+
export const globalDir = (): string => CONFIG.memoryRoot;
|
|
10
|
+
export const projectDir = (slug: string): string => join(CONFIG.memoryRoot, "projects", slug);
|
|
11
|
+
|
|
12
|
+
// Re-exports from sibling modules
|
|
13
|
+
export { readIndex, readIndexWithAge, listScopeDirs, listProjectSlugs } from "./memory-index.js";
|
|
14
|
+
export { atomicWrite } from "./atomic-writer.js";
|
|
15
|
+
export { parseFrontmatter, bumpUsage } from "./frontmatter.js";
|
|
16
|
+
export { sanitizeText, redactSecrets, stripPrivate, sanitizeForInjection } from "./sanitizer.js";
|
|
17
|
+
|
|
18
|
+
export function readPrompt(name: string): string | null {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(join(CONFIG.promptsDir, name), "utf-8");
|
|
21
|
+
} catch (e: any) {
|
|
22
|
+
log("missing prompt", name, "—", e.message);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// log.ts — append to ~/.pi/agent/mnemo/.plugin.log + stderr.
|
|
2
|
+
|
|
3
|
+
import { appendFileSync, statSync, renameSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { CONFIG } from "./config.js";
|
|
6
|
+
|
|
7
|
+
const LOG_FILE = () => join(CONFIG.memoryRoot, ".plugin.log");
|
|
8
|
+
|
|
9
|
+
export function log(...args: unknown[]): void {
|
|
10
|
+
const line = `[${new Date().toISOString()}] ${args.map(String).join(" ")}\n`;
|
|
11
|
+
try {
|
|
12
|
+
mkdirSync(dirname(LOG_FILE()), { recursive: true });
|
|
13
|
+
try {
|
|
14
|
+
if (statSync(LOG_FILE()).size > CONFIG.logMaxBytes) {
|
|
15
|
+
renameSync(LOG_FILE(), LOG_FILE() + ".1");
|
|
16
|
+
}
|
|
17
|
+
} catch { /* not created yet */ }
|
|
18
|
+
appendFileSync(LOG_FILE(), line);
|
|
19
|
+
} catch { /* disk issues must never break the agent */ }
|
|
20
|
+
console.error("[pi-mnemo]", ...args);
|
|
21
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// memory-index.ts — read MEMORY.md index files (with staleness annotation).
|
|
2
|
+
// Extracted from io.js for testability.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const MAX_LINES = 200;
|
|
8
|
+
const MAX_BYTES = 25000;
|
|
9
|
+
|
|
10
|
+
// Truncate MEMORY.md to line/byte caps so a bloated index can't blow up the
|
|
11
|
+
// system prompt. Line-truncates first (natural boundary), then byte-truncates
|
|
12
|
+
// at the last newline so we don't cut mid-line.
|
|
13
|
+
function truncate(raw: string): string {
|
|
14
|
+
const trimmed = (raw || "").trim();
|
|
15
|
+
if (!trimmed) return "";
|
|
16
|
+
const lines = trimmed.split("\n");
|
|
17
|
+
const wasLineTrunc = lines.length > MAX_LINES;
|
|
18
|
+
const wasByteTrunc = trimmed.length > MAX_BYTES;
|
|
19
|
+
if (!wasLineTrunc && !wasByteTrunc) return trimmed;
|
|
20
|
+
let out = wasLineTrunc ? lines.slice(0, MAX_LINES).join("\n") : trimmed;
|
|
21
|
+
if (out.length > MAX_BYTES) {
|
|
22
|
+
const cutAt = out.lastIndexOf("\n", MAX_BYTES);
|
|
23
|
+
out = out.slice(0, cutAt > 0 ? cutAt : MAX_BYTES);
|
|
24
|
+
}
|
|
25
|
+
const why =
|
|
26
|
+
(wasLineTrunc ? `${MAX_LINES} lines` : "") +
|
|
27
|
+
(wasLineTrunc && wasByteTrunc ? " and " : "") +
|
|
28
|
+
(wasByteTrunc ? `${MAX_BYTES} bytes` : "");
|
|
29
|
+
return (
|
|
30
|
+
out +
|
|
31
|
+
`\n\n> WARNING: MEMORY.md exceeded ${why}. Only part loaded — keep entries to one line under ~200 chars; move detail into topic files.`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function ageDays(mtimeMs: number): number {
|
|
36
|
+
return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Try alternate names agents sometimes write (MEMORY.md, INDEX.md, etc.) */
|
|
40
|
+
const INDEX_NAMES = ["MEMORY.md", "INDEX.md", "memory.md", "index.md"];
|
|
41
|
+
|
|
42
|
+
// Read a scope's MEMORY.md index (tolerates alternate names).
|
|
43
|
+
export function readIndex(dir: string): string {
|
|
44
|
+
for (const name of INDEX_NAMES) {
|
|
45
|
+
try {
|
|
46
|
+
const f = join(dir, name);
|
|
47
|
+
if (existsSync(f)) return truncate(readFileSync(f, "utf-8"));
|
|
48
|
+
} catch {
|
|
49
|
+
// continue
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read index + annotate each topic line with staleness based on the topic
|
|
56
|
+
// file's mtime. Memories >1 day old get "[N days old — verify against current
|
|
57
|
+
// code]" so the agent doesn't assert stale file:line / commands as fact.
|
|
58
|
+
export function readIndexWithAge(dir: string): string {
|
|
59
|
+
const raw = readIndex(dir);
|
|
60
|
+
if (!raw.trim()) return "";
|
|
61
|
+
return raw
|
|
62
|
+
.split("\n")
|
|
63
|
+
.map((line: string) => {
|
|
64
|
+
const m = line.match(/\]\(([^)]+)\)/);
|
|
65
|
+
if (!m) return line;
|
|
66
|
+
try {
|
|
67
|
+
const fpath = join(dir, m[1]);
|
|
68
|
+
// 过滤 deprecated_by(软删除——dream 合并标记的,不注入)
|
|
69
|
+
const c = readFileSync(fpath, "utf-8");
|
|
70
|
+
const fm = c.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
71
|
+
if (fm && /^deprecated_by:/m.test(fm[1])) return null;
|
|
72
|
+
const d = ageDays(statSync(fpath).mtimeMs);
|
|
73
|
+
if (d <= 1) return line;
|
|
74
|
+
return `${line} [${d} days old — verify against current code]`;
|
|
75
|
+
} catch {
|
|
76
|
+
return line;
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
.filter((line: string | null): line is string => line !== null)
|
|
80
|
+
.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* List all scope directories (global + project).
|
|
85
|
+
* Returns { globalDir, projectDir } for a given slug.
|
|
86
|
+
*/
|
|
87
|
+
export function listScopeDirs(
|
|
88
|
+
memoryRoot: string,
|
|
89
|
+
slug: string,
|
|
90
|
+
): { globalDir: string; projectDir: string } {
|
|
91
|
+
return {
|
|
92
|
+
globalDir: memoryRoot,
|
|
93
|
+
projectDir: join(memoryRoot, "projects", slug),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* List all project slug directories under memoryRoot/projects/.
|
|
99
|
+
* Used by dream backlog to find overdue slugs.
|
|
100
|
+
*/
|
|
101
|
+
export function listProjectSlugs(memoryRoot: string): string[] {
|
|
102
|
+
const projDir = join(memoryRoot, "projects");
|
|
103
|
+
try {
|
|
104
|
+
return readdirSync(projDir).filter((n) => {
|
|
105
|
+
try {
|
|
106
|
+
return statSync(join(projDir, n)).isDirectory();
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
} catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
}
|