Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
import dev.aceclaw.memory.TrendDetector;

import java.time.Instant;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
Expand All @@ -21,6 +23,8 @@
*/
public final class LearningMaintenanceCandidateBridge {

private static final Duration SOURCE_SUPPRESSION_WINDOW = Duration.ofHours(24);

private final CandidateStore candidateStore;

public LearningMaintenanceCandidateBridge(CandidateStore candidateStore) {
Expand All @@ -36,7 +40,11 @@ public BridgeResult bridge(String trigger,

int upserts = 0;
var observedCandidates = new ArrayList<LearningCandidate>();
var recentSources = recentSourceRefs();
for (var observation : toObservations(trigger, miningResult, trends)) {
if (shouldSuppress(observation, recentSources)) {
continue;
}
observedCandidates.add(candidateStore.upsert(observation));
upserts++;
}
Expand All @@ -52,6 +60,25 @@ public BridgeResult bridge(String trigger,
List.copyOf(transitions));
}

private boolean shouldSuppress(CandidateStore.CandidateObservation observation, java.util.Set<String> recentSources) {
String sourceRef = observation.sourceRef();
return sourceRef != null
&& !sourceRef.isBlank()
&& recentSources.contains(sourceRef);
}

private java.util.Set<String> recentSourceRefs() {
Instant cutoff = Instant.now().minus(SOURCE_SUPPRESSION_WINDOW);
var sourceRefs = new HashSet<String>();
for (var candidate : candidateStore.all()) {
if (candidate.lastSeenAt().isBefore(cutoff)) {
continue;
}
sourceRefs.addAll(candidate.sourceRefs());
}
return java.util.Set.copyOf(sourceRefs);
}

private static List<CandidateStore.CandidateObservation> toObservations(
String trigger,
CrossSessionPatternMiner.MiningResult miningResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,39 @@ void bridgesMinedSignalsIntoCandidateStore() throws Exception {
.extracting(LearningCandidate::state)
.isNotEmpty();
}

@Test
void suppressesRecentlyObservedMaintenanceSourceRefs() throws Exception {
var candidateStore = new CandidateStore(tempDir);
candidateStore.upsert(new CandidateStore.CandidateObservation(
dev.aceclaw.memory.MemoryEntry.Category.SUCCESSFUL_STRATEGY,
dev.aceclaw.memory.CandidateKind.SKILL_SEED,
"Inspect files, then run commands.",
"general",
List.of("cross-session", "converging-strategy", "maintenance"),
0.86,
1,
0,
"maintenance:scheduled:strategy:inspect-files>run-commands",
java.time.Instant.now()));

var bridge = new LearningMaintenanceCandidateBridge(candidateStore);
var result = bridge.bridge(
"scheduled",
new CrossSessionPatternMiner.MiningResult(
List.of(),
List.of(),
List.of(new CrossSessionPatternMiner.ConvergingStrategy(
"inspect-files>run-commands",
"Inspect files, then run commands.",
4,
8.0,
4.0,
0.86,
List.of("s1", "s2", "s3", "s4"))),
List.of()),
List.of());

assertThat(result.upserts()).isEqualTo(0);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,84 @@ public Optional<MemoryEntry> addIfAbsent(MemoryEntry.Category category, String c
}
}

/**
* Adds or replaces a memory entry identified by the same category + source within
* the same backing file. This is useful for maintenance-derived signals where the
* newest version should replace older variants instead of accumulating indefinitely.
*/
public Optional<MemoryEntry> upsertBySource(MemoryEntry.Category category, String content,
List<String> tags, String source,
boolean global, Path projectPath) {
Objects.requireNonNull(category, "category");
Objects.requireNonNull(content, "content");
Objects.requireNonNull(tags, "tags");
Objects.requireNonNull(source, "source");
accessLock.lock();
fileLock.lock();
try {
String fileName = targetFileName(global, projectPath);
var existing = entries.stream()
.filter(entry -> fileName.equals(entryFiles.get(entry.id())))
.filter(entry -> entry.category() == category)
.filter(entry -> Objects.equals(entry.source(), source))
.findFirst();

Instant now = Instant.now();
MemoryEntry entry;
if (existing.isPresent()) {
var prior = existing.get();
var unsigned = new MemoryEntry(
prior.id(),
category,
content,
tags,
now,
source,
null,
prior.accessCount(),
prior.lastAccessedAt());
String hmac = signer.sign(unsigned.signablePayload());
entry = new MemoryEntry(
prior.id(),
category,
content,
tags,
now,
source,
hmac,
prior.accessCount(),
prior.lastAccessedAt());
int index = entries.indexOf(prior);
if (index >= 0) {
entries.set(index, entry);
} else {
entries.add(entry);
}
entryFiles.put(entry.id(), fileName);
rewriteEntriesForFile(fileName);
log.debug("Upserted memory by source: category={}, source={}, file={}", category, source, fileName);
return Optional.of(entry);
}

String id = UUID.randomUUID().toString();
var unsigned = new MemoryEntry(id, category, content, tags, now, source, null, 0, null);
String hmac = signer.sign(unsigned.signablePayload());
entry = new MemoryEntry(id, category, content, tags, now, source, hmac, 0, null);
Files.writeString(memoryDir.resolve(fileName), mapper.writeValueAsString(entry) + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
entries.add(entry);
entryFiles.put(entry.id(), fileName);
log.debug("Inserted memory by source: category={}, source={}, file={}", category, source, fileName);
return Optional.of(entry);
} catch (IOException e) {
log.error("Failed to upsert memory by source: {}", e.getMessage());
return Optional.empty();
} finally {
fileLock.unlock();
accessLock.unlock();
}
}

/**
* Retrieves memories matching the given category filter and/or tag filter.
*
Expand Down Expand Up @@ -602,6 +680,23 @@ private void rewriteFile(Path file) {
}
}

private void rewriteEntriesForFile(String fileName) {
Path file = memoryDir.resolve(fileName);
try {
var lines = new ArrayList<String>();
for (var entry : entries) {
if (fileName.equals(entryFiles.get(entry.id()))) {
lines.add(mapper.writeValueAsString(entry));
}
}
Path tmpFile = file.resolveSibling(file.getFileName() + ".tmp");
Files.write(tmpFile, lines, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
Files.move(tmpFile, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
log.error("Failed to rewrite memory file {}: {}", file, e.getMessage());
}
}

private byte[] loadOrCreateKey() throws IOException {
Path keyFile = memoryDir.resolve(KEY_FILE);
if (Files.isRegularFile(keyFile)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ private static void persistPatterns(AutoMemoryStore memoryStore,
List<DegradationSignal> degradationSignals) {
for (var chain : frequentErrorChains) {
String key = chain.chain().stream().map(Enum::name).collect(Collectors.joining("->"));
memoryStore.addIfAbsent(
memoryStore.upsertBySource(
MemoryEntry.Category.ANTI_PATTERN,
"Across recent sessions, the error chain "
+ key + " recurred in " + chain.support() + " sessions. Avoid repeating this recovery path.",
Expand All @@ -333,7 +333,7 @@ private static void persistPatterns(AutoMemoryStore memoryStore,
}
for (var workflow : stableWorkflows) {
String key = stableWorkflowKey(workflow.signature());
memoryStore.addIfAbsent(
memoryStore.upsertBySource(
MemoryEntry.Category.WORKFLOW,
"Stable cross-session workflow observed in " + workflow.support()
+ " sessions: " + workflow.description(),
Expand All @@ -344,7 +344,7 @@ private static void persistPatterns(AutoMemoryStore memoryStore,
}
for (var strategy : convergingStrategies) {
String key = stableWorkflowKey(strategy.signature());
memoryStore.addIfAbsent(
memoryStore.upsertBySource(
MemoryEntry.Category.SUCCESSFUL_STRATEGY,
"A converging strategy is emerging: " + strategy.description()
+ " improved from average " + format(strategy.earlyAverageSteps())
Expand All @@ -357,7 +357,7 @@ private static void persistPatterns(AutoMemoryStore memoryStore,
}
for (var signal : degradationSignals) {
String key = normalize(signal.toolName());
memoryStore.addIfAbsent(
memoryStore.upsertBySource(
MemoryEntry.Category.FAILURE_SIGNAL,
"Tool '" + signal.toolName() + "' shows a degradation signal: average error rate rose from "
+ format(signal.earlierErrorRate()) + " to " + format(signal.laterErrorRate())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ public final class MemoryConsolidator {

private static final double SIMILARITY_THRESHOLD = 0.80;
private static final Duration AGE_THRESHOLD = Duration.ofDays(90);
private static final Duration MAINTENANCE_SIGNAL_AGE_THRESHOLD = Duration.ofDays(30);
private static final Duration SESSION_ANALYSIS_AGE_THRESHOLD = Duration.ofDays(45);
private static final String ARCHIVE_FILE = "archived.jsonl";

private MemoryConsolidator() {}
Expand Down Expand Up @@ -169,7 +171,7 @@ static int similarityMerge(List<MemoryEntry> entries) {
}

/**
* Pass 3: Archive entries older than 90 days with zero access count.
* Pass 3: Archive entries based on source/category-specific aging.
*
* <p>If {@code archiveDir} is null, pruning is skipped entirely to prevent
* silent data loss (entries would be removed without being archived).
Expand All @@ -180,11 +182,10 @@ static int agePrune(List<MemoryEntry> entries, Path archiveDir) {
return 0;
}

Instant cutoff = Instant.now().minus(AGE_THRESHOLD);
var toArchive = new ArrayList<MemoryEntry>();

for (var entry : entries) {
if (entry.createdAt().isBefore(cutoff) && entry.accessCount() == 0) {
if (shouldArchive(entry, Instant.now())) {
toArchive.add(entry);
}
}
Expand Down Expand Up @@ -215,6 +216,39 @@ static int agePrune(List<MemoryEntry> entries, Path archiveDir) {
return toArchive.size();
}

private static boolean shouldArchive(MemoryEntry entry, Instant now) {
Instant lastSignalAt = entry.lastAccessedAt() != null && entry.lastAccessedAt().isAfter(entry.createdAt())
? entry.lastAccessedAt()
: entry.createdAt();
Instant cutoff = now.minus(ageThreshold(entry));
if (!lastSignalAt.isBefore(cutoff)) {
return false;
}
if (isMaintenanceDerived(entry)) {
return entry.accessCount() <= 1;
}
return entry.accessCount() == 0;
}

private static Duration ageThreshold(MemoryEntry entry) {
String source = entry.source() == null ? "" : entry.source();
if (source.startsWith("trend:") || source.startsWith("cross-session:") || source.startsWith("maintenance:")) {
return MAINTENANCE_SIGNAL_AGE_THRESHOLD;
}
if (source.startsWith("session-analysis:")) {
return SESSION_ANALYSIS_AGE_THRESHOLD;
}
return AGE_THRESHOLD;
}

private static boolean isMaintenanceDerived(MemoryEntry entry) {
String source = entry.source() == null ? "" : entry.source();
return source.startsWith("trend:")
|| source.startsWith("cross-session:")
|| source.startsWith("maintenance:")
|| source.startsWith("session-analysis:");
}

/**
* Computes the Jaccard similarity between two strings based on token sets.
* Returns a value between 0.0 (no overlap) and 1.0 (identical tokens).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ private static void persistTrends(AutoMemoryStore memoryStore, Path projectPath,
if (category == null) {
continue;
}
memoryStore.addIfAbsent(
memoryStore.upsertBySource(
category,
trend.description(),
tagsFor(trend),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,35 @@ void accessCountIncrementedOnQueryAndSearch() {
assertThat(afterSearch.accessCount()).isEqualTo(2);
}

@Test
void upsertBySourceReplacesExistingMaintenanceSignal() throws IOException {
store.upsertBySource(
MemoryEntry.Category.FAILURE_SIGNAL,
"Tool 'bash' error rate rose from 10% to 30%",
List.of("trend", "bash"),
"trend:bash.errorRate:rising",
false,
projectPath);

store.upsertBySource(
MemoryEntry.Category.FAILURE_SIGNAL,
"Tool 'bash' error rate rose from 10% to 45%",
List.of("trend", "bash"),
"trend:bash.errorRate:rising",
false,
projectPath);

assertThat(store.query(MemoryEntry.Category.FAILURE_SIGNAL, List.of("trend"), 10))
.singleElement()
.satisfies(entry -> assertThat(entry.content()).contains("45%"));

var freshStore = new AutoMemoryStore(tempDir);
freshStore.load(projectPath);
assertThat(freshStore.query(MemoryEntry.Category.FAILURE_SIGNAL, List.of("trend"), 10))
.singleElement()
.satisfies(entry -> assertThat(entry.content()).contains("45%"));
}

@Test
void formatForPromptIncludesSelfLearningCategories() {
store.add(MemoryEntry.Category.ERROR_RECOVERY, "Fixed by clearing cache",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,26 @@ void detectsDegradationSignalsAndPersistsFailureSignal() throws Exception {
.anySatisfy(entry -> assertThat(entry.content()).contains("degradation signal"));
}

@Test
void rerunningMinerUpsertsMaintenanceSignalsInsteadOfAccumulatingCopies() throws Exception {
var workspace = tempDir.resolve("workspace-c");
var store = AutoMemoryStore.forWorkspace(tempDir, workspace);
var index = new HistoricalLogIndex(tempDir);
var miner = new CrossSessionPatternMiner();
var t0 = Instant.parse("2026-03-12T10:00:00Z");
String workspaceHash = WorkspacePaths.workspaceHash(workspace);

index.index(snapshot("s1", workspaceHash, t0, 6, 2, true));
index.index(snapshot("s2", workspaceHash, t0.plusSeconds(60), 5, 2, true));
index.index(snapshot("s3", workspaceHash, t0.plusSeconds(120), 4, 2, true));

miner.mine(index, store, workspaceHash, workspace, 20);
miner.mine(index, store, workspaceHash, workspace, 20);

assertThat(store.query(MemoryEntry.Category.ANTI_PATTERN, List.of("cross-session"), 20))
.hasSize(1);
}

private static HistoricalSessionSnapshot snapshot(String sessionId,
String workspaceHash,
Instant timestamp,
Expand Down
Loading