@drewpayment/mink 0.8.0 → 0.9.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/dashboard/out/404.html +1 -1
- package/dashboard/out/action-log.html +1 -1
- package/dashboard/out/action-log.txt +1 -1
- package/dashboard/out/activity.html +1 -1
- package/dashboard/out/activity.txt +1 -1
- package/dashboard/out/bugs.html +1 -1
- package/dashboard/out/bugs.txt +1 -1
- package/dashboard/out/capture.html +1 -1
- package/dashboard/out/capture.txt +1 -1
- package/dashboard/out/config.html +1 -1
- package/dashboard/out/config.txt +1 -1
- package/dashboard/out/daemon.html +1 -1
- package/dashboard/out/daemon.txt +1 -1
- package/dashboard/out/design.html +1 -1
- package/dashboard/out/design.txt +1 -1
- package/dashboard/out/discord.html +1 -1
- package/dashboard/out/discord.txt +1 -1
- package/dashboard/out/file-index.html +1 -1
- package/dashboard/out/file-index.txt +1 -1
- package/dashboard/out/index.html +1 -1
- package/dashboard/out/index.txt +1 -1
- package/dashboard/out/insights.html +1 -1
- package/dashboard/out/insights.txt +1 -1
- package/dashboard/out/learning.html +1 -1
- package/dashboard/out/learning.txt +1 -1
- package/dashboard/out/overview.html +1 -1
- package/dashboard/out/overview.txt +1 -1
- package/dashboard/out/scheduler.html +1 -1
- package/dashboard/out/scheduler.txt +1 -1
- package/dashboard/out/sync.html +1 -1
- package/dashboard/out/sync.txt +1 -1
- package/dashboard/out/tokens.html +1 -1
- package/dashboard/out/tokens.txt +1 -1
- package/dashboard/out/waste.html +1 -1
- package/dashboard/out/waste.txt +1 -1
- package/dashboard/out/wiki.html +1 -1
- package/dashboard/out/wiki.txt +1 -1
- package/dist/cli.js +2105 -1068
- package/package.json +1 -1
- package/src/commands/bug-search.ts +3 -3
- package/src/commands/detect-waste.ts +34 -25
- package/src/commands/init.ts +21 -21
- package/src/commands/post-read.ts +6 -3
- package/src/commands/post-write.ts +6 -3
- package/src/commands/pre-read.ts +14 -10
- package/src/commands/pre-write.ts +8 -5
- package/src/commands/reflect.ts +12 -7
- package/src/commands/session-start.ts +34 -3
- package/src/commands/session-stop.ts +10 -6
- package/src/commands/status.ts +29 -17
- package/src/commands/sync-migrate.ts +330 -0
- package/src/commands/sync.ts +75 -1
- package/src/commands/update.ts +4 -9
- package/src/core/conflict-park.ts +84 -0
- package/src/core/dashboard-api.ts +12 -31
- package/src/core/note-writer.ts +52 -6
- package/src/core/paths.ts +66 -10
- package/src/core/state-aggregator.ts +304 -0
- package/src/core/state-counters.ts +46 -0
- package/src/core/sync-merge-drivers.ts +247 -0
- package/src/core/sync.ts +150 -68
- package/src/core/token-ledger.ts +19 -3
- /package/dashboard/out/_next/static/{EC-_8nIOf1GnPrIqZ7Mk3 → r7Xr9mrUpunsz4QtD3jh1}/_buildManifest.js +0 -0
- /package/dashboard/out/_next/static/{EC-_8nIOf1GnPrIqZ7Mk3 → r7Xr9mrUpunsz4QtD3jh1}/_ssgManifest.js +0 -0
package/src/core/note-writer.ts
CHANGED
|
@@ -1,10 +1,55 @@
|
|
|
1
1
|
import { join } from "path";
|
|
2
2
|
import { existsSync, readFileSync } from "fs";
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
import { atomicWriteText, safeAppendText } from "./fs-utils";
|
|
4
5
|
import { categoryToDir, vaultDailyDir, vaultTemplates } from "./vault";
|
|
5
6
|
import { loadTemplate } from "./vault-templates";
|
|
7
|
+
import { getOrCreateDeviceId } from "./device";
|
|
6
8
|
import type { NoteMetadata, NoteFrontmatter, NoteCategory } from "../types/note";
|
|
7
9
|
|
|
10
|
+
const MAX_COLLISION_ATTEMPTS = 4;
|
|
11
|
+
|
|
12
|
+
function sha256(content: string): string {
|
|
13
|
+
return createHash("sha256").update(content).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Resolve the target path for a note write so two devices creating notes with
|
|
17
|
+
// the same slug never overwrite each other. Strategy:
|
|
18
|
+
// 1. If the path is free, use it as-is.
|
|
19
|
+
// 2. If the path holds the exact same content (idempotent re-save), reuse
|
|
20
|
+
// the path so the write is a no-op.
|
|
21
|
+
// 3. Otherwise append a short device suffix and retry. Fall back to a
|
|
22
|
+
// timestamp suffix if the device suffix is also taken.
|
|
23
|
+
function resolveUniqueNotePath(
|
|
24
|
+
dir: string,
|
|
25
|
+
baseSlug: string,
|
|
26
|
+
content: string
|
|
27
|
+
): string {
|
|
28
|
+
const targetHash = sha256(content);
|
|
29
|
+
const primary = join(dir, `${baseSlug}.md`);
|
|
30
|
+
if (!existsSync(primary)) return primary;
|
|
31
|
+
if (sameContent(primary, targetHash)) return primary;
|
|
32
|
+
|
|
33
|
+
const dev4 = getOrCreateDeviceId().replace(/-/g, "").slice(0, 4);
|
|
34
|
+
for (let i = 0; i < MAX_COLLISION_ATTEMPTS; i++) {
|
|
35
|
+
const suffix = i === 0 ? dev4 : `${dev4}-${i + 1}`;
|
|
36
|
+
const candidate = join(dir, `${baseSlug}-${suffix}.md`);
|
|
37
|
+
if (!existsSync(candidate)) return candidate;
|
|
38
|
+
if (sameContent(candidate, targetHash)) return candidate;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Final fallback: timestamp suffix (effectively guaranteed unique).
|
|
42
|
+
return join(dir, `${baseSlug}-${Date.now()}.md`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function sameContent(filePath: string, expectedHash: string): boolean {
|
|
46
|
+
try {
|
|
47
|
+
return sha256(readFileSync(filePath, "utf-8")) === expectedHash;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
8
53
|
export function slugifyTitle(title: string): string {
|
|
9
54
|
return title
|
|
10
55
|
.toLowerCase()
|
|
@@ -61,7 +106,6 @@ export function createNote(meta: NoteMetadata): {
|
|
|
61
106
|
const now = meta.created || new Date().toISOString();
|
|
62
107
|
const slug = slugifyTitle(meta.title);
|
|
63
108
|
const dir = categoryToDir(meta.category, meta.projectSlug);
|
|
64
|
-
const filePath = join(dir, `${slug}.md`);
|
|
65
109
|
|
|
66
110
|
let content: string;
|
|
67
111
|
|
|
@@ -78,6 +122,7 @@ export function createNote(meta: NoteMetadata): {
|
|
|
78
122
|
content = buildNoteContent(meta, now);
|
|
79
123
|
}
|
|
80
124
|
|
|
125
|
+
const filePath = resolveUniqueNotePath(dir, slug, content);
|
|
81
126
|
atomicWriteText(filePath, content);
|
|
82
127
|
return { filePath, content };
|
|
83
128
|
}
|
|
@@ -104,14 +149,15 @@ export function appendToDaily(date: string, content: string): string {
|
|
|
104
149
|
const filePath = join(dir, `${date}.md`);
|
|
105
150
|
|
|
106
151
|
if (existsSync(filePath)) {
|
|
107
|
-
|
|
152
|
+
// Append-only so `merge=union` cleanly resolves cross-device daily entries
|
|
153
|
+
// — full-file rewrites would defeat union merging and reintroduce conflict
|
|
154
|
+
// markers when two devices append on the same day.
|
|
108
155
|
const timestamp = new Date().toLocaleTimeString("en-US", {
|
|
109
156
|
hour: "2-digit",
|
|
110
157
|
minute: "2-digit",
|
|
111
158
|
hour12: false,
|
|
112
159
|
});
|
|
113
|
-
|
|
114
|
-
atomicWriteText(filePath, updated);
|
|
160
|
+
safeAppendText(filePath, `\n\n## ${timestamp}\n\n${content}\n`);
|
|
115
161
|
} else {
|
|
116
162
|
const now = new Date().toISOString();
|
|
117
163
|
const rendered = loadTemplate(vaultTemplates(), "daily-note", {
|
|
@@ -197,7 +243,7 @@ export function ingestFile(
|
|
|
197
243
|
|
|
198
244
|
const slug = slugifyTitle(title);
|
|
199
245
|
const dir = categoryToDir(meta.category, meta.projectSlug);
|
|
200
|
-
const filePath =
|
|
246
|
+
const filePath = resolveUniqueNotePath(dir, slug, content);
|
|
201
247
|
atomicWriteText(filePath, content);
|
|
202
248
|
return { filePath, content };
|
|
203
249
|
}
|
package/src/core/paths.ts
CHANGED
|
@@ -2,15 +2,26 @@ import { join } from "path";
|
|
|
2
2
|
import { homedir } from "os";
|
|
3
3
|
import { generateProjectId } from "./project-id";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
// Resolved per-call so tests can override via MINK_ROOT_OVERRIDE without
|
|
6
|
+
// reloading modules. Production callers get the default homedir/.mink path.
|
|
7
|
+
function resolveMinkRoot(): string {
|
|
8
|
+
return process.env.MINK_ROOT_OVERRIDE || join(homedir(), ".mink");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MINK_ROOT = resolveMinkRoot();
|
|
6
12
|
|
|
7
13
|
export function minkRoot(): string {
|
|
14
|
+
// Re-resolve when the test override is in play so individual tests can
|
|
15
|
+
// point at their own temp dir without contaminating each other.
|
|
16
|
+
if (process.env.MINK_ROOT_OVERRIDE) {
|
|
17
|
+
return process.env.MINK_ROOT_OVERRIDE;
|
|
18
|
+
}
|
|
8
19
|
return MINK_ROOT;
|
|
9
20
|
}
|
|
10
21
|
|
|
11
22
|
export function projectDir(cwd: string): string {
|
|
12
23
|
const id = generateProjectId(cwd);
|
|
13
|
-
return join(
|
|
24
|
+
return join(minkRoot(), "projects", id);
|
|
14
25
|
}
|
|
15
26
|
|
|
16
27
|
export function sessionPath(cwd: string): string {
|
|
@@ -46,11 +57,11 @@ export function actionLogPath(cwd: string): string {
|
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
export function schedulerPidPath(): string {
|
|
49
|
-
return join(
|
|
60
|
+
return join(minkRoot(), "scheduler.pid");
|
|
50
61
|
}
|
|
51
62
|
|
|
52
63
|
export function schedulerLogPath(): string {
|
|
53
|
-
return join(
|
|
64
|
+
return join(minkRoot(), "scheduler.log");
|
|
54
65
|
}
|
|
55
66
|
|
|
56
67
|
export function schedulerManifestPath(cwd: string): string {
|
|
@@ -58,27 +69,27 @@ export function schedulerManifestPath(cwd: string): string {
|
|
|
58
69
|
}
|
|
59
70
|
|
|
60
71
|
export function channelPidPath(): string {
|
|
61
|
-
return join(
|
|
72
|
+
return join(minkRoot(), "channel.pid");
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
export function channelLogPath(): string {
|
|
65
|
-
return join(
|
|
76
|
+
return join(minkRoot(), "channel.log");
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
export function globalConfigPath(): string {
|
|
69
|
-
return join(
|
|
80
|
+
return join(minkRoot(), "config");
|
|
70
81
|
}
|
|
71
82
|
|
|
72
83
|
export function localConfigPath(): string {
|
|
73
|
-
return join(
|
|
84
|
+
return join(minkRoot(), "config.local");
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
export function deviceIdPath(): string {
|
|
77
|
-
return join(
|
|
88
|
+
return join(minkRoot(), "device-id");
|
|
78
89
|
}
|
|
79
90
|
|
|
80
91
|
export function deviceRegistryPath(): string {
|
|
81
|
-
return join(
|
|
92
|
+
return join(minkRoot(), "devices.json");
|
|
82
93
|
}
|
|
83
94
|
|
|
84
95
|
export function projectMetaPath(cwd: string): string {
|
|
@@ -89,6 +100,51 @@ export function backupDirPath(cwd: string): string {
|
|
|
89
100
|
return join(projectDir(cwd), "backups");
|
|
90
101
|
}
|
|
91
102
|
|
|
103
|
+
// ── Sync v2 — shard-aware paths ────────────────────────────────────────────
|
|
104
|
+
// Per-device shards isolate machine-rewritten state files so two devices never
|
|
105
|
+
// write to the same path. Aggregators in state-aggregator.ts compose the
|
|
106
|
+
// authoritative view by reading every device's shard plus the legacy paths
|
|
107
|
+
// above. The legacy helpers (tokenLedgerPath, bugMemoryPath, actionLogPath,
|
|
108
|
+
// tokenLedgerArchivePath) remain valid for fallback reads during the migration
|
|
109
|
+
// window — they are NOT removed.
|
|
110
|
+
|
|
111
|
+
export function syncVersionPath(): string {
|
|
112
|
+
return join(minkRoot(), ".mink-sync-version");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function projectStateDir(cwd: string): string {
|
|
116
|
+
return join(projectDir(cwd), "state");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function deviceShardDir(cwd: string, deviceId: string): string {
|
|
120
|
+
return join(projectStateDir(cwd), deviceId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function tokenLedgerShardPath(cwd: string, deviceId: string): string {
|
|
124
|
+
return join(deviceShardDir(cwd, deviceId), "token-ledger.json");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function tokenLedgerArchiveShardPath(cwd: string, deviceId: string): string {
|
|
128
|
+
return join(deviceShardDir(cwd, deviceId), "token-ledger-archive.json");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function bugMemoryShardPath(cwd: string, deviceId: string): string {
|
|
132
|
+
return join(deviceShardDir(cwd, deviceId), "bug-memory.json");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function actionLogShardPath(cwd: string, deviceId: string): string {
|
|
136
|
+
return join(deviceShardDir(cwd, deviceId), "action-log.md");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function learningMemorySidecarPath(cwd: string, deviceId: string): string {
|
|
140
|
+
return join(projectDir(cwd), `learning-memory.${deviceId}.md`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Per-device telemetry counters split out of file-index.json (gitignored, not synced).
|
|
144
|
+
export function fileIndexCountersPath(cwd: string): string {
|
|
145
|
+
return join(projectDir(cwd), ".mink-state-counters.json");
|
|
146
|
+
}
|
|
147
|
+
|
|
92
148
|
export function designCapturesDir(cwd: string): string {
|
|
93
149
|
return join(projectDir(cwd), "design-captures");
|
|
94
150
|
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { projectDir } from "./paths";
|
|
4
|
+
import {
|
|
5
|
+
loadLedger,
|
|
6
|
+
loadArchive,
|
|
7
|
+
createEmptyLedger,
|
|
8
|
+
} from "./token-ledger";
|
|
9
|
+
import { loadBugMemory, createEmptyBugMemory } from "./bug-memory";
|
|
10
|
+
import {
|
|
11
|
+
parseLearningMemory,
|
|
12
|
+
createEmptyLearningMemory,
|
|
13
|
+
} from "./learning-memory";
|
|
14
|
+
import { parseLogSessions, safeReadLog } from "./action-log";
|
|
15
|
+
import type {
|
|
16
|
+
TokenLedger,
|
|
17
|
+
LedgerSession,
|
|
18
|
+
LifetimeCounters,
|
|
19
|
+
} from "../types/token-ledger";
|
|
20
|
+
import type { BugMemory, BugEntry } from "../types/bug-memory";
|
|
21
|
+
import type {
|
|
22
|
+
LearningMemory,
|
|
23
|
+
SectionName,
|
|
24
|
+
} from "../types/learning-memory";
|
|
25
|
+
|
|
26
|
+
// ── Shard discovery ────────────────────────────────────────────────────────
|
|
27
|
+
// All aggregators take a project state directory (the path returned by
|
|
28
|
+
// projectDir(cwd)). The cwd-based variants below are thin wrappers for the
|
|
29
|
+
// common case where the caller has cwd in hand.
|
|
30
|
+
|
|
31
|
+
function listDeviceShardsAt(projDir: string): string[] {
|
|
32
|
+
const stateDir = join(projDir, "state");
|
|
33
|
+
if (!existsSync(stateDir)) return [];
|
|
34
|
+
try {
|
|
35
|
+
return readdirSync(stateDir).filter((name) => {
|
|
36
|
+
try {
|
|
37
|
+
return statSync(join(stateDir, name)).isDirectory();
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SIDECAR_RE = /^learning-memory\.([^.]+)\.md$/;
|
|
48
|
+
|
|
49
|
+
function listLearningMemorySidecarPathsAt(projDir: string): string[] {
|
|
50
|
+
if (!existsSync(projDir)) return [];
|
|
51
|
+
try {
|
|
52
|
+
return readdirSync(projDir)
|
|
53
|
+
.filter((f) => SIDECAR_RE.test(f))
|
|
54
|
+
.map((f) => join(projDir, f));
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function shardPath(projDir: string, deviceId: string, file: string): string {
|
|
61
|
+
return join(projDir, "state", deviceId, file);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Token ledger ───────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
function addLifetime(target: LifetimeCounters, source: LifetimeCounters): void {
|
|
67
|
+
target.totalTokens += source.totalTokens;
|
|
68
|
+
target.totalReads += source.totalReads;
|
|
69
|
+
target.totalWrites += source.totalWrites;
|
|
70
|
+
target.totalSessions += source.totalSessions;
|
|
71
|
+
target.totalFileIndexHits += source.totalFileIndexHits;
|
|
72
|
+
target.totalFileIndexMisses += source.totalFileIndexMisses;
|
|
73
|
+
target.totalRepeatedReads += source.totalRepeatedReads;
|
|
74
|
+
target.totalEstimatedSavings += source.totalEstimatedSavings;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function aggregateTokenLedgerAt(projDir: string): TokenLedger {
|
|
78
|
+
const merged = createEmptyLedger();
|
|
79
|
+
const seenSessions = new Set<string>();
|
|
80
|
+
|
|
81
|
+
// Sum lifetime counters from every source (each shard + legacy). Lifetime
|
|
82
|
+
// persists across archive cycles, so deriving from active sessions alone
|
|
83
|
+
// would lose archived totals. Migration atomically moves legacy → shard
|
|
84
|
+
// (`git mv`), so a session never lives in both simultaneously and lifetime
|
|
85
|
+
// counters do not double-count in production.
|
|
86
|
+
const sources = [
|
|
87
|
+
...listDeviceShardsAt(projDir).map((id) =>
|
|
88
|
+
shardPath(projDir, id, "token-ledger.json")
|
|
89
|
+
),
|
|
90
|
+
join(projDir, "token-ledger.json"),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// Track waste-flags across sources, deduped by (pattern, detectedAt) so
|
|
94
|
+
// each device's flags remain visible without spamming duplicates.
|
|
95
|
+
const seenFlagKeys = new Set<string>();
|
|
96
|
+
const wasteFlags: NonNullable<TokenLedger["wasteFlags"]> = [];
|
|
97
|
+
|
|
98
|
+
for (const path of sources) {
|
|
99
|
+
if (!existsSync(path)) continue;
|
|
100
|
+
const ledger = loadLedger(path);
|
|
101
|
+
addLifetime(merged.lifetime, ledger.lifetime);
|
|
102
|
+
for (const session of ledger.sessions) {
|
|
103
|
+
if (seenSessions.has(session.sessionId)) continue;
|
|
104
|
+
seenSessions.add(session.sessionId);
|
|
105
|
+
merged.sessions.push(session);
|
|
106
|
+
}
|
|
107
|
+
if (ledger.wasteFlags) {
|
|
108
|
+
for (const flag of ledger.wasteFlags) {
|
|
109
|
+
const key = `${flag.pattern}|${flag.detectedAt}`;
|
|
110
|
+
if (seenFlagKeys.has(key)) continue;
|
|
111
|
+
seenFlagKeys.add(key);
|
|
112
|
+
wasteFlags.push(flag);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (wasteFlags.length > 0) {
|
|
118
|
+
merged.wasteFlags = wasteFlags;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
merged.sessions.sort((a, b) =>
|
|
122
|
+
a.startTimestamp.localeCompare(b.startTimestamp)
|
|
123
|
+
);
|
|
124
|
+
return merged;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function aggregateTokenLedger(cwd: string): TokenLedger {
|
|
128
|
+
return aggregateTokenLedgerAt(projectDir(cwd));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function aggregateTokenLedgerArchiveAt(
|
|
132
|
+
projDir: string
|
|
133
|
+
): LedgerSession[] {
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
const archived: LedgerSession[] = [];
|
|
136
|
+
|
|
137
|
+
const sources = [
|
|
138
|
+
...listDeviceShardsAt(projDir).map((id) =>
|
|
139
|
+
shardPath(projDir, id, "token-ledger-archive.json")
|
|
140
|
+
),
|
|
141
|
+
join(projDir, "token-ledger-archive.json"),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
for (const path of sources) {
|
|
145
|
+
if (!existsSync(path)) continue;
|
|
146
|
+
for (const session of loadArchive(path)) {
|
|
147
|
+
if (seen.has(session.sessionId)) continue;
|
|
148
|
+
seen.add(session.sessionId);
|
|
149
|
+
archived.push(session);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
archived.sort((a, b) => a.startTimestamp.localeCompare(b.startTimestamp));
|
|
154
|
+
return archived;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function aggregateTokenLedgerArchive(cwd: string): LedgerSession[] {
|
|
158
|
+
return aggregateTokenLedgerArchiveAt(projectDir(cwd));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Bug memory ─────────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
export function aggregateBugMemoryAt(projDir: string): BugMemory {
|
|
164
|
+
const byId = new Map<string, BugEntry>();
|
|
165
|
+
let maxNextId = 1;
|
|
166
|
+
|
|
167
|
+
const sources = [
|
|
168
|
+
...listDeviceShardsAt(projDir).map((id) =>
|
|
169
|
+
shardPath(projDir, id, "bug-memory.json")
|
|
170
|
+
),
|
|
171
|
+
join(projDir, "bug-memory.json"),
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
for (const path of sources) {
|
|
175
|
+
if (!existsSync(path)) continue;
|
|
176
|
+
const mem = loadBugMemory(path);
|
|
177
|
+
if (mem.nextId > maxNextId) maxNextId = mem.nextId;
|
|
178
|
+
for (const entry of mem.entries) {
|
|
179
|
+
const existing = byId.get(entry.id);
|
|
180
|
+
if (!existing) {
|
|
181
|
+
byId.set(entry.id, { ...entry });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
existing.occurrenceCount = Math.max(
|
|
185
|
+
existing.occurrenceCount,
|
|
186
|
+
entry.occurrenceCount
|
|
187
|
+
);
|
|
188
|
+
if (entry.lastSeenAt > existing.lastSeenAt) {
|
|
189
|
+
existing.lastSeenAt = entry.lastSeenAt;
|
|
190
|
+
}
|
|
191
|
+
if (entry.createdAt < existing.createdAt) {
|
|
192
|
+
existing.createdAt = entry.createdAt;
|
|
193
|
+
}
|
|
194
|
+
const tags = new Set([...existing.tags, ...entry.tags]);
|
|
195
|
+
existing.tags = [...tags];
|
|
196
|
+
const related = new Set([
|
|
197
|
+
...existing.relatedBugIds,
|
|
198
|
+
...entry.relatedBugIds,
|
|
199
|
+
]);
|
|
200
|
+
existing.relatedBugIds = [...related];
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
entries: [...byId.values()].sort((a, b) =>
|
|
206
|
+
a.lastSeenAt < b.lastSeenAt ? 1 : -1
|
|
207
|
+
),
|
|
208
|
+
nextId: maxNextId,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function aggregateBugMemory(cwd: string): BugMemory {
|
|
213
|
+
return aggregateBugMemoryAt(projectDir(cwd));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ── Action log ─────────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
export function aggregateActionLogAt(projDir: string): string {
|
|
219
|
+
type Block = { date: string; content: string; offset: number };
|
|
220
|
+
const blocks: Block[] = [];
|
|
221
|
+
let order = 0;
|
|
222
|
+
|
|
223
|
+
const sources = [
|
|
224
|
+
...listDeviceShardsAt(projDir).map((id) =>
|
|
225
|
+
shardPath(projDir, id, "action-log.md")
|
|
226
|
+
),
|
|
227
|
+
join(projDir, "action-log.md"),
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
for (const path of sources) {
|
|
231
|
+
if (!existsSync(path)) continue;
|
|
232
|
+
const content = safeReadLog(path);
|
|
233
|
+
if (!content) continue;
|
|
234
|
+
const sessions = parseLogSessions(content);
|
|
235
|
+
for (const session of sessions) {
|
|
236
|
+
blocks.push({
|
|
237
|
+
date: session.date,
|
|
238
|
+
content: session.content,
|
|
239
|
+
offset: order++,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
blocks.sort((a, b) => {
|
|
245
|
+
if (a.date !== b.date) return a.date.localeCompare(b.date);
|
|
246
|
+
return a.offset - b.offset;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
return blocks.map((b) => b.content).join("");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function aggregateActionLog(cwd: string): string {
|
|
253
|
+
return aggregateActionLogAt(projectDir(cwd));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Learning memory ────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
export function aggregateLearningMemoryAt(projDir: string): LearningMemory {
|
|
259
|
+
const canonicalPath = join(projDir, "learning-memory.md");
|
|
260
|
+
let merged: LearningMemory;
|
|
261
|
+
if (existsSync(canonicalPath)) {
|
|
262
|
+
try {
|
|
263
|
+
merged = parseLearningMemory(readFileSync(canonicalPath, "utf-8"));
|
|
264
|
+
} catch {
|
|
265
|
+
merged = createEmptyLearningMemory("unknown");
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
merged = createEmptyLearningMemory("unknown");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const sidecarPath of listLearningMemorySidecarPathsAt(projDir)) {
|
|
272
|
+
let sidecar: LearningMemory;
|
|
273
|
+
try {
|
|
274
|
+
sidecar = parseLearningMemory(readFileSync(sidecarPath, "utf-8"));
|
|
275
|
+
} catch {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (
|
|
280
|
+
merged.projectName === "unknown" &&
|
|
281
|
+
sidecar.projectName !== "unknown"
|
|
282
|
+
) {
|
|
283
|
+
merged.projectName = sidecar.projectName;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const section of Object.keys(sidecar.sections) as SectionName[]) {
|
|
287
|
+
const existing = new Set(
|
|
288
|
+
merged.sections[section].map((e) => e.trim().toLowerCase())
|
|
289
|
+
);
|
|
290
|
+
for (const entry of sidecar.sections[section]) {
|
|
291
|
+
const norm = entry.trim().toLowerCase();
|
|
292
|
+
if (existing.has(norm)) continue;
|
|
293
|
+
existing.add(norm);
|
|
294
|
+
merged.sections[section].push(entry);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return merged;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function aggregateLearningMemory(cwd: string): LearningMemory {
|
|
303
|
+
return aggregateLearningMemoryAt(projectDir(cwd));
|
|
304
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { atomicWriteJson, safeReadJson } from "./fs-utils";
|
|
2
|
+
import { fileIndexCountersPath } from "./paths";
|
|
3
|
+
|
|
4
|
+
// Per-device telemetry counters. Lives at projects/<id>/.mink-state-counters.json
|
|
5
|
+
// and is gitignored so each device's counts never collide. Aggregated views
|
|
6
|
+
// (dashboard, status) sum across devices via aggregateStateCounters().
|
|
7
|
+
|
|
8
|
+
export interface StateCounters {
|
|
9
|
+
fileIndexHits: number;
|
|
10
|
+
fileIndexMisses: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function emptyCounters(): StateCounters {
|
|
14
|
+
return { fileIndexHits: 0, fileIndexMisses: 0 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isStateCounters(value: unknown): value is StateCounters {
|
|
18
|
+
if (value === null || typeof value !== "object") return false;
|
|
19
|
+
const obj = value as Record<string, unknown>;
|
|
20
|
+
return (
|
|
21
|
+
typeof obj.fileIndexHits === "number" &&
|
|
22
|
+
typeof obj.fileIndexMisses === "number"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function loadCounters(cwd: string): StateCounters {
|
|
27
|
+
const raw = safeReadJson(fileIndexCountersPath(cwd));
|
|
28
|
+
if (raw !== null && isStateCounters(raw)) return raw;
|
|
29
|
+
return emptyCounters();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function saveCounters(cwd: string, counters: StateCounters): void {
|
|
33
|
+
atomicWriteJson(fileIndexCountersPath(cwd), counters);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function incrementFileIndexHit(cwd: string): void {
|
|
37
|
+
const c = loadCounters(cwd);
|
|
38
|
+
c.fileIndexHits++;
|
|
39
|
+
saveCounters(cwd, c);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function incrementFileIndexMiss(cwd: string): void {
|
|
43
|
+
const c = loadCounters(cwd);
|
|
44
|
+
c.fileIndexMisses++;
|
|
45
|
+
saveCounters(cwd, c);
|
|
46
|
+
}
|