mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
import { createHash } from "crypto";
|
||
import type { ActiveFile, LongTermMemoryEntry, LongTermType, OpenError } from "./types.ts";
|
||
import { LONG_TERM_LIMITS } from "./types.ts";
|
||
|
||
function id(prefix: string): string {
|
||
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
function hash(value: string): string {
|
||
return createHash("sha1").update(value).digest("hex").slice(0, 12);
|
||
}
|
||
|
||
/**
|
||
* Check if a memory request is negated (e.g., "不要記住", "don't remember").
|
||
* Uses structured adjacency detection to avoid false positives.
|
||
*/
|
||
function isNegatedMemoryRequest(text: string, matchIndex: number): boolean {
|
||
const prefix = text.slice(Math.max(0, matchIndex - 30), matchIndex);
|
||
|
||
// Chinese negative: 不要/別/不用 + optional 幫我, must be adjacent to trigger
|
||
if (/(?:不要|別|别|不用|不需要|勿)\s*(?:幫我|帮我)?\s*$/u.test(prefix)) {
|
||
return true;
|
||
}
|
||
|
||
// English negative: do not / don't / never / not + optional please, must be adjacent to trigger
|
||
if (/(?:do\s+not|don't|dont|never|not)\s+(?:please\s+)?$/i.test(prefix)) {
|
||
return true;
|
||
}
|
||
|
||
// Japanese negative
|
||
if (/(?:覚えないで|記憶しないで|メモしないで)\s*$/u.test(prefix)) {
|
||
return true;
|
||
}
|
||
|
||
// Korean negative
|
||
if (/(?:기억하지\s*마|기억하지마|메모하지\s*마|메모하지마)\s*$/u.test(prefix)) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
export function extractExplicitMemories(text: string): LongTermMemoryEntry[] {
|
||
// 注意:所有pattern必須有 g flag,因為使用 matchAll()
|
||
// Pattern 必須在行首匹配,避免匹配到句子中間的非指令式用法
|
||
const patterns = [
|
||
// 中文:請/幫我 + 記住 + 可選後綴
|
||
/(?:^|\n)\s*(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/gim,
|
||
// 日文(長詞優先):覚えておいて must come before 覚えて
|
||
/(?:^|\n)\s*(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/gim,
|
||
// 韓文(長詞優先):기억해줘/메모해줘 must come before 기억해/메모해
|
||
/(?:^|\n)\s*(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/gim,
|
||
// 英文:remember this/that - 必須在行首,避免 "to remember" 非指令匹配
|
||
/(?:^|\n)\s*(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/gim,
|
||
// save/add to memory
|
||
/(?:^|\n)\s*(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/gim,
|
||
// commit to memory
|
||
/(?:^|\n)\s*(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/gim,
|
||
// going forward / from now on
|
||
/(?:从现在开始|從現在開始|从今以后|從今以後|from now on|going forward)[::,,]?\s*(.+)$/gim,
|
||
// 偏好
|
||
/(?:我的偏好是|我偏好|以后请|以後請|以后都|以後都)[::,,]?\s*(.+)$/gim,
|
||
/(?:^|\n)\s*(?:my preference is|i prefer)[::,,]?\s*(.+)$/gim,
|
||
];
|
||
|
||
const now = new Date().toISOString();
|
||
const entries: LongTermMemoryEntry[] = [];
|
||
const seen = new Set<string>();
|
||
|
||
for (const pattern of patterns) {
|
||
for (const match of text.matchAll(pattern)) {
|
||
const body = match[1]?.trim();
|
||
if (!body || body.length < 8) continue;
|
||
|
||
// Calculate actual trigger position (after possible newline)
|
||
const triggerIndex = match.index! + (match[0].match(/^[\s\n]*/)?.[0]?.length || 0);
|
||
|
||
// Check if this is a negated request (e.g., "不要記住")
|
||
if (isNegatedMemoryRequest(text, triggerIndex)) continue;
|
||
|
||
// Check if it's a deferral (e.g., "later", "next time")
|
||
if (/^(再说|再說|later|next time)$/i.test(body)) continue;
|
||
|
||
// Dedupe by canonical body
|
||
const key = body.toLowerCase().replace(/\s+/g, " ").trim();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
|
||
const type = classifyExplicitMemory(body);
|
||
entries.push({
|
||
id: id("mem"),
|
||
type,
|
||
text: body.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||
source: "explicit",
|
||
confidence: 1,
|
||
status: "active",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
staleAfterDays: staleAfterDaysFor(type),
|
||
});
|
||
}
|
||
}
|
||
|
||
return entries;
|
||
}
|
||
|
||
function classifyExplicitMemory(text: string): LongTermType {
|
||
const lower = text.toLowerCase();
|
||
if (/https?:\/\/|linear|slack|notion|dashboard|grafana/.test(lower)) return "reference";
|
||
if (/decide|decision|choose|chosen|决定|決定|选择|選擇/.test(lower)) return "decision";
|
||
if (/project|repo|项目|專案/.test(lower)) return "project";
|
||
return "feedback";
|
||
}
|
||
|
||
export function staleAfterDaysFor(type: LongTermType): number | undefined {
|
||
if (type === "feedback") return undefined;
|
||
if (type === "decision") return 45;
|
||
if (type === "project") return 60;
|
||
return 90;
|
||
}
|
||
|
||
export function extractActiveFiles(
|
||
toolName: string,
|
||
args: Record<string, unknown>,
|
||
output: string,
|
||
): Array<{ path: string; action: ActiveFile["action"] }> {
|
||
if (toolName === "read" && typeof args.filePath === "string") return [{ path: args.filePath, action: "read" }];
|
||
if (toolName === "edit" && typeof args.filePath === "string") return [{ path: args.filePath, action: "edit" }];
|
||
if (toolName === "write" && typeof args.filePath === "string") return [{ path: args.filePath, action: "write" }];
|
||
if (toolName === "grep") return extractGrepPaths(output).map(path => ({ path, action: "grep" as const }));
|
||
return [];
|
||
}
|
||
|
||
function extractGrepPaths(output: string): string[] {
|
||
const matches = output.match(/^(\/[^\n]+\.(ts|tsx|js|jsx|json|md|py|go|rs|toml|yml|yaml)):/gm) ?? [];
|
||
return [...new Set(matches.map(match => match.replace(/:$/, "")))].slice(0, 10);
|
||
}
|
||
|
||
function isErrorLine(line: string, knownValidationCommand: boolean): boolean {
|
||
// 無條件捕捉的強訊號
|
||
if (/TS\d{4}|ERR!|Traceback \(most recent call last\):|panic:/i.test(line)) return true;
|
||
|
||
// Error 類型前綴(獨立行)
|
||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError|Exception):/i.test(line)) {
|
||
return true;
|
||
}
|
||
|
||
// 已知 validation command 才用寬鬆匹配
|
||
if (knownValidationCommand) {
|
||
return /\b(error|failed|failure|exception)\b/i.test(line);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
export function extractErrorsFromBash(command: string, output: string): OpenError[] {
|
||
const classifiedCategory = classifyCommand(command);
|
||
const knownValidationCommand = classifiedCategory !== null;
|
||
|
||
const lines = output
|
||
.split("\n")
|
||
.filter(line => isErrorLine(line, knownValidationCommand))
|
||
.slice(0, 5);
|
||
if (lines.length === 0) return [];
|
||
|
||
const category = classifiedCategory ?? "runtime";
|
||
const summary = lines.join(" ").slice(0, 280);
|
||
const fingerprint = hash(`${category}:${summary.toLowerCase().replace(/\s+/g, " ")}`);
|
||
const now = Date.now();
|
||
|
||
return [
|
||
{
|
||
id: `err_${fingerprint}`,
|
||
category,
|
||
summary,
|
||
command,
|
||
file: extractFirstPath(summary),
|
||
fingerprint,
|
||
status: "open",
|
||
firstSeen: now,
|
||
lastSeen: now,
|
||
seenCount: 1,
|
||
},
|
||
];
|
||
}
|
||
|
||
export function classifyCommand(command: string): OpenError["category"] | null {
|
||
const c = command.toLowerCase();
|
||
if (/\b(tsc|typecheck)\b/.test(c)) return "typecheck";
|
||
if (/\b(test|vitest|jest|mocha|pytest|go test|cargo test)\b/.test(c)) return "test";
|
||
if (/\b(lint|eslint|biome)\b/.test(c)) return "lint";
|
||
if (/\b(build|vite build|webpack|tsup)\b/.test(c)) return "build";
|
||
return null;
|
||
}
|
||
|
||
function normalizeCandidateBody(body: string): { text: string; hadTrigger: boolean } | null {
|
||
const text = body.trim();
|
||
const triggerPatterns = [
|
||
/(?:请|請)?(?:帮我|幫我)?(?:记住|記住|记得|記得|记下来|記下來)(?:这一点|這一點|这点|這點|这个|這個)?[::,,]?\s*(.+)$/im,
|
||
/(?:覚えておいて|覚えて|忘れないで|メモして)[::,,]?\s*(.+)$/im,
|
||
/(?:기억해줘|기억해|잊지 마|잊지마|메모해줘|메모해)[::,,]?\s*(.+)$/im,
|
||
/(?:please\s+)?remember\s+(?:this|that)?[::,,]?\s*(.+)$/im,
|
||
/(?:please\s+)?(?:save|add)\s+(?:this|that)?\s*(?:to|in)\s+memory[::,,]?\s*(.+)$/im,
|
||
/(?:please\s+)?commit\s+(?:this|that)?\s*to memory[::,,]?\s*(.+)$/im,
|
||
];
|
||
|
||
for (const pattern of triggerPatterns) {
|
||
const match = pattern.exec(text);
|
||
if (!match) continue;
|
||
|
||
const triggerIndex = match.index + (match[0].match(/^\s*/)?.[0]?.length || 0);
|
||
if (isNegatedMemoryRequest(text, triggerIndex)) return null;
|
||
|
||
const extracted = match[1]?.trim();
|
||
return extracted ? { text: extracted, hadTrigger: true } : null;
|
||
}
|
||
|
||
return { text, hadTrigger: false };
|
||
}
|
||
|
||
function extractFirstPath(text: string): string | undefined {
|
||
return text.match(/[\w./-]+\.(ts|tsx|js|jsx|json|md|py|go|rs)/)?.[0];
|
||
}
|
||
|
||
/**
|
||
* Quality gate for workspace memory candidates.
|
||
* Rejects low-quality entries like git hashes, error messages, etc.
|
||
*/
|
||
function shouldAcceptWorkspaceMemoryCandidate(
|
||
entry: {
|
||
type: LongTermType;
|
||
text: string;
|
||
},
|
||
options: {
|
||
fromMemoryTrigger?: boolean;
|
||
} = {},
|
||
): boolean {
|
||
const text = entry.text.trim();
|
||
const minLength = options.fromMemoryTrigger ? 6 : 20;
|
||
|
||
// Too short (with type-specific allowlist for stable config values)
|
||
if (entry.type === "reference" && /\b(?:admin\s+)?pin\s|scrypt|n=\d+|r=\d+|p=\d+/i.test(text)) {
|
||
// Stable config values can be short — allow below generic min length
|
||
} else if (text.length < minLength) {
|
||
return false;
|
||
}
|
||
|
||
// Git history / commit hash
|
||
if (/\b[0-9a-f]{7,40}\b/.test(text)) return false;
|
||
if (/^(fix|feat|chore|docs|refactor|test):/i.test(text)) return false;
|
||
|
||
// Raw error / stack trace
|
||
if (/^\s*(Error|TypeError|ReferenceError|SyntaxError):/i.test(text)) return false;
|
||
if (/at \S+ \([^)]+:\d+:\d+\)/.test(text)) return false;
|
||
|
||
// Active file list
|
||
if (/^(modified|created|deleted|renamed)\s+\S+\.\S+$/i.test(text)) return false;
|
||
|
||
// Temporary progress
|
||
if (/^(currently|now|pending|in progress|todo|wip):/i.test(text)) return false;
|
||
|
||
// Code signature / API doc
|
||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
|
||
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
|
||
|
||
// Path-heavy facts (rediscoverable from repo)
|
||
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
||
if (pathCount > 2) return false;
|
||
|
||
// Session-specific progress snapshots for project type
|
||
if (entry.type === "project") {
|
||
if (isProjectSnapshotViolation(text)) return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function isProjectSnapshotViolation(text: string): boolean {
|
||
// Test/suite counts
|
||
if (/\d+\s+tests?\s+pass(?:ed)?/i.test(text)) return true;
|
||
if (/\d+\s+suites?\s+(?:pass|fail)/i.test(text)) return true;
|
||
|
||
// File counts with snapshot/process context only, not static limits
|
||
if (/\d+\s*(?:個|个)?\s*(?:files?|文件)/i.test(text)) {
|
||
const hasSnapshotContext = /同步|synced|uploaded|downloaded|completed|generated|created|modified|processed|完成/i.test(text);
|
||
const hasLimitContext = /limit|max|maximum|min|minimum|supports?|allowed|per\s+(?:batch|request|upload)/i.test(text);
|
||
if (hasSnapshotContext && !hasLimitContext) return true;
|
||
}
|
||
|
||
// Phase/Wave/Sprint/Milestone/Task progress
|
||
if (/(?:phases?|waves?|sprints?|milestones?|tasks?)\s*\d+(?:\s*[-–]\s*\d+)?/i.test(text)) {
|
||
if (/completed|done|finished|完成/i.test(text)) return true;
|
||
}
|
||
if (/(?:已完成|完成).{0,30}(?:phases?|waves?|sprints?|milestones?|tasks?)/i.test(text)) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Extract candidate block from summary using multiple formats.
|
||
* Supports: Plain text label, Markdown section, legacy XML.
|
||
*/
|
||
function extractCandidateBlock(summary: string): string | null {
|
||
// 1. Plain text label (primary format, no Markdown header)
|
||
const plainMatch = summary.match(/Memory candidates:\s*\n([\s\S]*?)(?:\n[A-Z][a-z]+ [a-z]+:|\n##\s|$)/i);
|
||
if (plainMatch) return plainMatch[1];
|
||
|
||
// 2. Markdown section (legacy)
|
||
const markdownMatch = summary.match(/##\s*Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||
if (markdownMatch) return markdownMatch[1];
|
||
|
||
// 3. Legacy "Workspace Memory Candidates" section
|
||
const legacyMatch = summary.match(/##\s*Workspace Memory Candidates\s*\n([\s\S]*?)(?:\n##\s|$)/i);
|
||
if (legacyMatch) return legacyMatch[1];
|
||
|
||
// 4. Legacy XML block (backward compatible)
|
||
const xmlMatch = summary.match(/<workspace_memory_candidates>([\s\S]*?)<\/workspace_memory_candidates>/i);
|
||
if (xmlMatch) return xmlMatch[1];
|
||
|
||
return null;
|
||
}
|
||
|
||
export function parseWorkspaceMemoryCandidates(summary: string): LongTermMemoryEntry[] {
|
||
const block = extractCandidateBlock(summary);
|
||
if (!block) return [];
|
||
|
||
const now = new Date().toISOString();
|
||
const entries: LongTermMemoryEntry[] = [];
|
||
|
||
for (const line of block.split("\n")) {
|
||
// Accept both "- [type] text" (bracketed) and "- type text" (bracketless)
|
||
const item = line.trim().match(
|
||
/^-\s*(?:\[(feedback|project|decision|reference)\]|(feedback|project|decision|reference)\b)\s+(.+)$/i,
|
||
);
|
||
if (!item) continue;
|
||
const type = (item[1] ?? item[2]).toLowerCase() as LongTermType;
|
||
const normalizedBody = normalizeCandidateBody(item[3]);
|
||
if (!normalizedBody) continue;
|
||
|
||
const minLength = normalizedBody.hadTrigger ? 6 : 12;
|
||
if (normalizedBody.text.length < minLength) continue;
|
||
|
||
// Apply quality gate
|
||
if (!shouldAcceptWorkspaceMemoryCandidate(
|
||
{ type, text: normalizedBody.text },
|
||
{ fromMemoryTrigger: normalizedBody.hadTrigger },
|
||
)) continue;
|
||
|
||
entries.push({
|
||
id: id("mem"),
|
||
type,
|
||
text: normalizedBody.text.slice(0, LONG_TERM_LIMITS.maxEntryTextChars),
|
||
source: "compaction",
|
||
confidence: 0.75,
|
||
status: "active",
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
staleAfterDays: staleAfterDaysFor(type),
|
||
});
|
||
}
|
||
|
||
return entries;
|
||
}
|