herdr-plugin-amq 0.1.4 → 0.1.5

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/herdr-plugin.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  id = "cabra.amq"
2
2
  name = "Herdr AMQ"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  min_herdr_version = "0.7.0"
5
5
  description = "Agent Message Queue (AMQ) bridge daemon, mailbox monitor, and dashboard for Herdr"
6
6
  platforms = ["linux", "macos"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-plugin-amq",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Herdr plugin for AMQ (Agent Message Queue) autonomous bridge, status monitoring, and AGmail dashboard",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
package/src/blobs.mjs CHANGED
@@ -161,10 +161,57 @@ export function getBlob(sha256, amqRoot) {
161
161
 
162
162
  const gitRefCache = new Map();
163
163
  const headCommitCache = new Map();
164
+ const timestampCommitCache = new Map();
164
165
 
165
166
  export function clearGitRefCache() {
166
167
  gitRefCache.clear();
167
168
  headCommitCache.clear();
169
+ timestampCommitCache.clear();
170
+ }
171
+
172
+ /**
173
+ * Resolve the git commit active before or at a given ISO timestamp.
174
+ */
175
+ export function getCommitAtTimestamp(repoRoot, timestamp) {
176
+ if (!repoRoot || !timestamp) return null;
177
+ const iso = typeof timestamp === "string" ? timestamp.trim() : new Date(timestamp).toISOString();
178
+ const cacheKey = `${repoRoot}:${iso}`;
179
+ if (timestampCommitCache.has(cacheKey)) {
180
+ return timestampCommitCache.get(cacheKey);
181
+ }
182
+
183
+ try {
184
+ const commit = execFileSync("git", ["rev-list", "-n", "1", `--before=${iso}`, "HEAD"], {
185
+ cwd: repoRoot,
186
+ encoding: "utf8",
187
+ stdio: ["ignore", "pipe", "ignore"],
188
+ }).trim();
189
+
190
+ const result = commit && /^[a-f0-9]{7,40}$/i.test(commit) ? commit : null;
191
+ timestampCommitCache.set(cacheKey, result);
192
+ return result;
193
+ } catch {
194
+ timestampCommitCache.set(cacheKey, null);
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Extract commit hashes explicitly mentioned in message text.
201
+ */
202
+ export function extractMentionedCommits(text = "") {
203
+ if (!text || typeof text !== "string") return [];
204
+ const commits = [];
205
+ const regex = /\b(?:commit|commitado|commitada|fixado|merge|sha|rev)?[:\s*`"'(]*([0-9a-f]{7,40})\b/gi;
206
+ let match;
207
+ while ((match = regex.exec(text)) !== null) {
208
+ const hex = match[1].toLowerCase();
209
+ if (/^[0-9]+$/.test(hex) && hex.length < 40) continue;
210
+ if (!commits.includes(hex)) {
211
+ commits.push(hex);
212
+ }
213
+ }
214
+ return commits;
168
215
  }
169
216
 
170
217
  /**
@@ -290,7 +337,7 @@ export function readGitRef(repoRoot, commitSha, relativePath) {
290
337
  * - Committed files in repoRoot can be pinned to git HEAD.
291
338
  * - Already pinned/stored items retain their immutable URLs.
292
339
  */
293
- export function ingestAttachment(candidate, amqRoot, repoRoot) {
340
+ export function ingestAttachment(candidate, amqRoot, repoRoot, { timestamp = null, text = "" } = {}) {
294
341
  if (!candidate) return null;
295
342
 
296
343
  // Already a structured descriptor
@@ -316,7 +363,7 @@ export function ingestAttachment(candidate, amqRoot, repoRoot) {
316
363
  }
317
364
 
318
365
  if (candidate.path) {
319
- return ingestAttachment(candidate.path, amqRoot, repoRoot);
366
+ return ingestAttachment(candidate.path, amqRoot, repoRoot, { timestamp, text });
320
367
  }
321
368
  }
322
369
 
@@ -363,16 +410,47 @@ export function ingestAttachment(candidate, amqRoot, repoRoot) {
363
410
  return storeBlob(diskPath, amqRoot, path.basename(diskPath));
364
411
  }
365
412
 
366
- // If it's inside repoRoot and git is present, try pinning to git
413
+ // If it's inside repoRoot and git is present, pin to commit at timestamp (or HEAD)
367
414
  if (repoRoot && diskPath.startsWith(repoRoot)) {
368
415
  const rel = path.relative(repoRoot, diskPath);
369
- const gitRef = pinGitRef(repoRoot, rel, "HEAD");
416
+ const commit = (timestamp ? getCommitAtTimestamp(repoRoot, timestamp) : null) || "HEAD";
417
+ const gitRef = pinGitRef(repoRoot, rel, commit);
370
418
  if (gitRef) return gitRef;
371
419
  }
372
420
 
373
421
  // Default fallback for existing files: store into blobstore for guarantee
374
422
  return storeBlob(diskPath, amqRoot, path.basename(diskPath));
375
423
  }
424
+
425
+ // ─── Historical Git Pinning Fallback ───
426
+ // If the file does not exist on disk right now (e.g. deleted or renamed in subsequent commits),
427
+ // check if it existed in Git at the time the message was created or at a commit mentioned in the message!
428
+ if (!diskPath && repoRoot) {
429
+ const cleanRel = raw.startsWith(repoRoot) ? path.relative(repoRoot, raw) : raw.replace(/^[/\\]+/, "");
430
+ if (!cleanRel.includes("..") && !path.isAbsolute(cleanRel)) {
431
+ const candidateCommits = [];
432
+ if (text) {
433
+ candidateCommits.push(...extractMentionedCommits(text));
434
+ }
435
+ if (timestamp) {
436
+ const atTime = getCommitAtTimestamp(repoRoot, timestamp);
437
+ if (atTime && !candidateCommits.includes(atTime)) {
438
+ candidateCommits.push(atTime);
439
+ }
440
+ }
441
+ candidateCommits.push("HEAD");
442
+
443
+ for (const commitSha of candidateCommits) {
444
+ const gitRef = pinGitRef(repoRoot, cleanRel, commitSha);
445
+ if (gitRef && gitRef.exists) {
446
+ return {
447
+ ...gitRef,
448
+ pinnedAt: timestamp || null,
449
+ };
450
+ }
451
+ }
452
+ }
453
+ }
376
454
  }
377
455
 
378
456
  return null;
package/src/store.mjs CHANGED
@@ -163,7 +163,7 @@ export function resolveAttachmentPath(ref, amqRoot) {
163
163
  * Extract attachments and referenced images/logs from body or frontmatter,
164
164
  * verifying presence on disk and providing existence flags.
165
165
  */
166
- export function extractAttachments(body = "", metaAttachments = [], amqRoot = null) {
166
+ export function extractAttachments(body = "", metaAttachments = [], amqRoot = null, meta = {}) {
167
167
  const attachments = [];
168
168
  const seen = new Set();
169
169
  const repoRoot = getRepoRootFromAmq(amqRoot);
@@ -184,10 +184,13 @@ export function extractAttachments(body = "", metaAttachments = [], amqRoot = nu
184
184
  return;
185
185
  }
186
186
 
187
- // 1. Try hybrid CAS ingestion / Git pinning (Option A + B)
187
+ // 1. Try hybrid CAS ingestion / Git pinning (Option A + B) with timestamp
188
188
  if (amqRoot) {
189
189
  try {
190
- const ingested = ingestAttachment(rawRef, amqRoot, repoRoot);
190
+ const ingested = ingestAttachment(rawRef, amqRoot, repoRoot, {
191
+ timestamp: meta?.created || null,
192
+ text: body,
193
+ });
191
194
  if (ingested && ingested.exists) {
192
195
  attachments.push({
193
196
  ...ingested,
@@ -394,7 +397,7 @@ export function parseMessageFile(filePath, amqRoot = null) {
394
397
  if (isOutbox) folder = "sent";
395
398
 
396
399
  const root = amqRoot || (filePath.includes("/agents/") ? filePath.split(path.sep + "agents" + path.sep)[0] : null);
397
- const attachments = extractAttachments(body, meta.attachments, root);
400
+ const attachments = extractAttachments(body, meta.attachments, root, meta);
398
401
  const hasImage = attachments.some((a) => a.isImage);
399
402
  const hasAttachment = attachments.length > 0;
400
403