mirror of
https://github.com/sdwolf4103/opencode-working-memory.git
synced 2026-07-17 12:56:42 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 034dfe8d91 | |||
| e3ebdbfe9f | |||
| fdebd304f6 | |||
| 77d60abf5f | |||
| 560f63f96b | |||
| 11361abc91 | |||
| e071095422 | |||
| 909d6c7767 | |||
| c697f63c67 | |||
| 25b673fbb7 | |||
| acaa829df4 |
@@ -0,0 +1,32 @@
|
|||||||
|
name: compatibility
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: "0 9 * * 1"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
locked:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
- run: npm install
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
opencode-latest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
- run: npm install
|
||||||
|
- run: npm install --no-save @opencode-ai/plugin@latest
|
||||||
|
- run: npm run typecheck
|
||||||
|
- run: npm test
|
||||||
@@ -5,6 +5,43 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [1.3.2] - 2026-04-27
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Compatibility CI now installs dependencies with `npm install` so it works in this no-lockfile repository.
|
||||||
|
- Compatibility CI now runs on Node 24, matching the test command's `--experimental-strip-types` requirement.
|
||||||
|
|
||||||
|
## [1.3.1] - 2026-04-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Pending journal retention: max 50 entries, 30-day TTL, automatic pruning on save.
|
||||||
|
- Plugin capability test to catch missing OpenCode hooks before release.
|
||||||
|
- CI workflow for weekly OpenCode plugin API compatibility testing.
|
||||||
|
- Indirect prompt-injection filtering for workspace memory candidates.
|
||||||
|
- Expanded credential redaction for common API key, token, secret, credential, auth, and private-key labels.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Pending memory journal entries are now bounded and pruned instead of growing indefinitely.
|
||||||
|
- Adversarial memory candidates that try to override system instructions are rejected before storage.
|
||||||
|
- Broader credential-like labels are redacted from workspace memory text.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Memory dedupe is now repo-agnostic: project/reference entries use exact canonical text plus generic URL/path identity, while decision/feedback entries no longer use repository-specific topic heuristics.
|
||||||
|
- OpenCode plugin compatibility is documented and declared as `>=1.2.0 <2.0.0`.
|
||||||
|
- README limitations now concisely document compatibility, secret handling, semantic-memory scope, plugin ordering, and multi-process write boundaries.
|
||||||
|
|
||||||
|
### Known Limitations
|
||||||
|
|
||||||
|
- Compatibility is tested against OpenCode plugin API `>=1.2.0 <2.0.0`.
|
||||||
|
- Credential redaction is best-effort; do not store secrets.
|
||||||
|
- This is working memory, not semantic search.
|
||||||
|
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||||
|
- Multi-process writes to the same workspace are not fully serialized.
|
||||||
|
|
||||||
## [1.3.0] - 2026-04-27
|
## [1.3.0] - 2026-04-27
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -214,9 +214,17 @@ npm run typecheck
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- OpenCode >= 1.0.0
|
- OpenCode plugin API `>=1.2.0 <2.0.0`
|
||||||
- Node.js >= 18.0.0
|
- Node.js >= 18.0.0
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Requires OpenCode plugin API `>=1.2.0 <2.0.0`; OpenCode hook changes may break compatibility.
|
||||||
|
- Not a secret manager. Credential redaction is best-effort. Do not store secrets.
|
||||||
|
- Working memory only. No semantic search, embeddings, or vector knowledge base.
|
||||||
|
- Other prompt or compaction plugins may conflict depending on plugin order.
|
||||||
|
- Multiple OpenCode processes on the same workspace may race on local files.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License. See [LICENSE](LICENSE) for details.
|
MIT License. See [LICENSE](LICENSE) for details.
|
||||||
|
|||||||
@@ -1,5 +1,52 @@
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
|
|
||||||
|
## 1.3.2 (2026-04-27)
|
||||||
|
|
||||||
|
### CI Compatibility Patch
|
||||||
|
|
||||||
|
- Fixed the compatibility workflow so dependency installation works without a committed lockfile.
|
||||||
|
- Moved compatibility CI to Node 24 so TypeScript-stripping tests run correctly.
|
||||||
|
- No runtime or storage changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.3.1 (2026-04-27)
|
||||||
|
|
||||||
|
### Security and Reliability Patch
|
||||||
|
|
||||||
|
This patch release keeps the v1.3 memory-consolidation model intact while tightening storage safety, compatibility checks, and repository-agnostic dedupe behavior.
|
||||||
|
|
||||||
|
### What Changed
|
||||||
|
|
||||||
|
- **Bounded pending journal**: pending memories are capped at 50 entries and pruned after 30 days.
|
||||||
|
- **Security hardening**: workspace memory candidates now reject indirect prompt-injection attempts, and redaction covers broader token, secret, credential, auth, and private-key labels.
|
||||||
|
- **Compatibility coverage**: plugin capability tests and weekly OpenCode plugin API compatibility CI help catch hook drift before release.
|
||||||
|
- **Repo-agnostic dedupe**: long-term memory dedupe no longer depends on hardcoded project-specific topic rules; project/reference memories use generic URL/path identity plus exact canonical matching.
|
||||||
|
- **Clearer limitations**: README and changelog now document compatibility, best-effort secret redaction, working-memory scope, plugin ordering, and multi-process write boundaries.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
- Thanks @StevenChoo for the security hardening contribution in #3.
|
||||||
|
|
||||||
|
### Upgrade Notes
|
||||||
|
|
||||||
|
- No user migration is required.
|
||||||
|
- Existing workspace memory and pending journal files remain compatible.
|
||||||
|
- The OpenCode config entry stays the same:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": ["opencode-working-memory"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- `npm test`
|
||||||
|
- `npm run typecheck`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 1.3.0 (2026-04-27)
|
## 1.3.0 (2026-04-27)
|
||||||
|
|
||||||
### Better Memory Consolidation
|
### Better Memory Consolidation
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-working-memory",
|
"name": "opencode-working-memory",
|
||||||
"version": "1.3.0",
|
"version": "1.3.2",
|
||||||
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
"description": "Three-layer memory architecture for OpenCode with workspace memory and hot session state",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
"build": "node -e \"console.log('No build step required: OpenCode loads index.ts directly')\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test --experimental-strip-types tests/*.test.ts"
|
"test": "node --test --experimental-strip-types tests/*.test.ts",
|
||||||
|
"check:compat": "npm install --no-save @opencode-ai/plugin@latest && npm run typecheck && npm test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"opencode",
|
"opencode",
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme",
|
"homepage": "https://github.com/sdwolf4103/opencode-working-memory#readme",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opencode-ai/plugin": "^1.2.0"
|
"@opencode-ai/plugin": ">=1.2.0 <2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.3.0",
|
||||||
|
|||||||
@@ -263,6 +263,12 @@ function shouldAcceptWorkspaceMemoryCandidate(
|
|||||||
if (/^(function|class|interface|type|const|let|var)\s+\w+/.test(text)) return false;
|
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;
|
if (/^(GET|POST|PUT|DELETE|PATCH)\s+\//.test(text)) return false;
|
||||||
|
|
||||||
|
// Indirect Prompt Injection / Adversarial Instructions
|
||||||
|
// Rejects attempts to overwrite system behavior or "ignore" rules.
|
||||||
|
// comparative "instead of" is allowed.
|
||||||
|
if (/\b(ignore\s+all|ignore\s+previous|ignore\s+instruction|overwrite\s+system|overwrite\s+rules|forget\s+all|delete\s+root)\b/i.test(text)) return false;
|
||||||
|
if (/\b(ignore|instruction|overwrite)\b/i.test(text) && /\b(previous|all|rules|behavior|prompt|system)\b/i.test(text)) return false;
|
||||||
|
|
||||||
// Path-heavy facts (rediscoverable from repo)
|
// Path-heavy facts (rediscoverable from repo)
|
||||||
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
const pathCount = (text.match(/\/[\w.-]+(\/[\w.-]+)+/g) || []).length;
|
||||||
if (pathCount > 2) return false;
|
if (pathCount > 2) return false;
|
||||||
|
|||||||
+70
-5
@@ -2,6 +2,20 @@ import type { LongTermMemoryEntry, PendingMemoryJournalStore } from "./types.ts"
|
|||||||
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
import { workspaceKey, workspacePendingJournalPath } from "./paths.ts";
|
||||||
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
import { atomicWriteJSON, readJSON, updateJSON } from "./storage.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retention limits for the pending memory journal.
|
||||||
|
*
|
||||||
|
* The journal is a scratchpad for memories that haven't been promoted to
|
||||||
|
* workspace memory yet. It should not grow unboundedly:
|
||||||
|
* - maxEntries: Hard cap on number of pending entries
|
||||||
|
* - maxAgeDays: Prune entries older than this (compaction candidates that
|
||||||
|
* were never promoted)
|
||||||
|
*/
|
||||||
|
export const PENDING_JOURNAL_LIMITS = {
|
||||||
|
maxEntries: 50,
|
||||||
|
maxAgeDays: 30,
|
||||||
|
} as const;
|
||||||
|
|
||||||
function normalizeMemoryText(text: string): string {
|
function normalizeMemoryText(text: string): string {
|
||||||
return text
|
return text
|
||||||
.normalize("NFKC")
|
.normalize("NFKC")
|
||||||
@@ -37,6 +51,57 @@ function dedupeByText(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the effective timestamp for an entry, preferring updatedAt over createdAt.
|
||||||
|
* Returns 0 if both are invalid/missing.
|
||||||
|
*/
|
||||||
|
function entryTime(entry: LongTermMemoryEntry): number {
|
||||||
|
const updatedAt = entry.updatedAt ? new Date(entry.updatedAt).getTime() : NaN;
|
||||||
|
if (!Number.isNaN(updatedAt)) return updatedAt;
|
||||||
|
|
||||||
|
const createdAt = entry.createdAt ? new Date(entry.createdAt).getTime() : NaN;
|
||||||
|
if (!Number.isNaN(createdAt)) return createdAt;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStaleEntry(entry: LongTermMemoryEntry, maxAgeDays: number): boolean {
|
||||||
|
const time = entryTime(entry);
|
||||||
|
|
||||||
|
// If timestamp is 0 (both invalid), treat as stale
|
||||||
|
if (time === 0) return true;
|
||||||
|
|
||||||
|
const ageMs = Date.now() - time;
|
||||||
|
const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return ageMs > maxAgeMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRetention(
|
||||||
|
entries: LongTermMemoryEntry[],
|
||||||
|
maxEntries: number,
|
||||||
|
maxAgeDays: number,
|
||||||
|
): LongTermMemoryEntry[] {
|
||||||
|
// 1. Dedupe first
|
||||||
|
const deduped = dedupeByText(entries);
|
||||||
|
|
||||||
|
// 2. Remove stale entries
|
||||||
|
const freshEntries = deduped.filter(entry => !isStaleEntry(entry, maxAgeDays));
|
||||||
|
|
||||||
|
// 3. Sort by entryTime descending (newest first) for cap, using updatedAt then createdAt
|
||||||
|
const sorted = [...freshEntries].sort((a, b) => {
|
||||||
|
return entryTime(b) - entryTime(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Keep maxEntries newest
|
||||||
|
const capped = sorted.slice(0, maxEntries);
|
||||||
|
|
||||||
|
// 5. Restore stable order (oldest-to-newest) for consistency with existing code
|
||||||
|
return capped.sort((a, b) => {
|
||||||
|
return entryTime(a) - entryTime(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeJournal(
|
function normalizeJournal(
|
||||||
root: string,
|
root: string,
|
||||||
store: PendingMemoryJournalStore,
|
store: PendingMemoryJournalStore,
|
||||||
@@ -44,11 +109,11 @@ function normalizeJournal(
|
|||||||
return workspaceKey(root).then(key => ({
|
return workspaceKey(root).then(key => ({
|
||||||
version: 1,
|
version: 1,
|
||||||
workspace: { root, key },
|
workspace: { root, key },
|
||||||
// TODO(memory-consolidation follow-up): add the deferred pending journal
|
entries: applyRetention(
|
||||||
// safety cap (max entries and old compaction pruning). P0 currently relies
|
Array.isArray(store.entries) ? store.entries : [],
|
||||||
// on promotion accounting to clear terminal compaction candidates without
|
PENDING_JOURNAL_LIMITS.maxEntries,
|
||||||
// changing journal capacity behavior.
|
PENDING_JOURNAL_LIMITS.maxAgeDays,
|
||||||
entries: dedupeByText(Array.isArray(store.entries) ? store.entries : []),
|
),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
+75
-68
@@ -11,10 +11,12 @@ const SECRET_VALUE = String.raw`[^` + "`" + String.raw`'",,,\s\[]+`;
|
|||||||
|
|
||||||
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
const PASSWORD_LABELS = /password|passwd|pwd|密碼|密码|パスワード|비밀번호|contraseña|mot de passe|passwort/i;
|
||||||
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
const USERNAME_LABELS = /username|user name|用戶名|用户名|ユーザー名|사용자명|usuario|utilisateur|benutzer/i;
|
||||||
|
const SENSITIVE_LABELS = /api[_-]?key|token|bearer|secret|credential|auth|auth[_-]?key|private[_-]?key/i;
|
||||||
|
|
||||||
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
const PIN_PREFIX = String.raw`(\bPIN\b(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||||
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
const PASSWORD_PREFIX = String.raw`((?:${PASSWORD_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||||
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
const USERNAME_PREFIX = String.raw`((?:${USERNAME_LABELS.source})(?:\s*(?:是|=|:|:)\s*|\s+(?![是=::])))`;
|
||||||
|
const SENSITIVE_PREFIX = String.raw`((?:${SENSITIVE_LABELS.source})(?:\s*(?:推|是|=|:|:)\s*|[::]\s*))`;
|
||||||
|
|
||||||
export type MemoryConsolidationReason =
|
export type MemoryConsolidationReason =
|
||||||
| "promoted"
|
| "promoted"
|
||||||
@@ -231,6 +233,12 @@ export function redactCredentials(text: string): string {
|
|||||||
"$1[REDACTED]",
|
"$1[REDACTED]",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 4. Standalone sensitive keys/tokens
|
||||||
|
result = result.replace(
|
||||||
|
new RegExp(String.raw`${SENSITIVE_PREFIX}[\`'"]?(${SECRET_VALUE})`, "gi"),
|
||||||
|
"$1[REDACTED]",
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,72 +313,80 @@ export function workspaceMemoryExactKey(entry: Pick<LongTermMemoryEntry, "type"
|
|||||||
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
return `${entry.type}:${canonicalMemoryText(entry.text)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract entity/destination keys for project and reference dedup */
|
function normalizeUrlIdentity(raw: string): string | null {
|
||||||
function extractEntityKey(text: string): string | null {
|
const cleaned = raw.replace(/[),.;:!?]+$/g, "");
|
||||||
const normalized = canonicalMemoryText(text);
|
try {
|
||||||
// Check known key phrases (bilingual-friendly)
|
const url = new URL(cleaned);
|
||||||
// opencode + agenthub plugin system
|
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
||||||
if (/opencode.*agenthub/i.test(normalized)) {
|
url.protocol = url.protocol.toLowerCase();
|
||||||
return "opencode-agenthub plugin system";
|
url.hostname = url.hostname.toLowerCase();
|
||||||
|
url.hash = "";
|
||||||
|
if (url.pathname.length > 1) {
|
||||||
|
url.pathname = url.pathname.replace(/\/+$/g, "");
|
||||||
|
}
|
||||||
|
return `url:${url.toString()}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
// For generic config references, fall back to canonical text dedup — no entity key
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract decision topic key for supersession detection */
|
function normalizePathIdentity(raw: string): string | null {
|
||||||
function decisionTopicKey(text: string): string | null {
|
const unwrapped = raw
|
||||||
const normalized = text.toLowerCase();
|
.trim()
|
||||||
// Parser format versions
|
.replace(/^[`"']+|[`"']+$/g, "")
|
||||||
if (/parser.*formats?|supports?\s*\d+\s*format/i.test(normalized)) {
|
.replace(/[),.;:!?]+$/g, "")
|
||||||
return "parser-supported-formats";
|
.replace(/\\+/g, "/");
|
||||||
}
|
|
||||||
// Compaction template replacement
|
if (!unwrapped) return null;
|
||||||
if (/compaction.*template|output\.prompt|template.*replace/i.test(normalized)) {
|
const collapsed = unwrapped.startsWith("/")
|
||||||
return "compaction-template-replacement";
|
? `/${unwrapped.slice(1).replace(/\/+$/g, "/").replace(/\/+/g, "/")}`
|
||||||
}
|
: unwrapped.replace(/\/+/g, "/");
|
||||||
// Plugin loading
|
const withoutTrailingSlash = collapsed.length > 1 ? collapsed.replace(/\/+$/g, "") : collapsed;
|
||||||
if (/plugin.*load|npm.*cache|plugin.*config/i.test(normalized)) {
|
return `path:${withoutTrailingSlash}`;
|
||||||
return "plugin-loading-config";
|
|
||||||
}
|
|
||||||
// Output format changes (purple/italic, YAML frontmatter, etc)
|
|
||||||
if (/purple.*italic|markup|markdown.*render|frontmatter/i.test(normalized)) {
|
|
||||||
return "output-format-rendering";
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract feedback topic key for supersession detection */
|
function isConcretePathIdentity(pathIdentity: string): boolean {
|
||||||
function feedbackTopicKey(text: string): string | null {
|
const path = pathIdentity.slice("path:".length);
|
||||||
const normalized = text.toLowerCase();
|
if (!path || path === "." || path === "..") return false;
|
||||||
// Purple/italic rendering issue
|
|
||||||
if (/purple.*italic/i.test(normalized)) {
|
if (path.startsWith("/")) return true;
|
||||||
return "purple-italic-rendering";
|
if (/^\.\.?\//.test(path)) return true;
|
||||||
|
if (/^\.[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||||
|
if (/^[A-Za-z0-9_.-]+\//.test(path)) return true;
|
||||||
|
return /\.(?:json|jsonc|ts|tsx|js|jsx|mjs|cjs|md|yaml|yml|toml|lock|config)$/i.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConcretePathIdentity(raw: string): string | null {
|
||||||
|
const pathIdentity = normalizePathIdentity(raw);
|
||||||
|
if (!pathIdentity) return null;
|
||||||
|
return isConcretePathIdentity(pathIdentity) ? pathIdentity : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractConcreteIdentityKey(text: string): string | null {
|
||||||
|
const urlMatch = text.match(/https?:\/\/[^\s`"'<>]+/i);
|
||||||
|
if (urlMatch) {
|
||||||
|
const urlIdentity = normalizeUrlIdentity(urlMatch[0]);
|
||||||
|
if (urlIdentity) return urlIdentity;
|
||||||
}
|
}
|
||||||
// Browser login/server errors (500 internal_error)
|
|
||||||
if (/login.*500|500.*internal|internal_error|server.*error/i.test(normalized)) {
|
const wrappedPathPattern = /[`"']([^`"']+)[`"']/g;
|
||||||
return "server-error";
|
for (const match of text.matchAll(wrappedPathPattern)) {
|
||||||
|
const pathIdentity = normalizeConcretePathIdentity(match[1]);
|
||||||
|
if (pathIdentity) return pathIdentity;
|
||||||
}
|
}
|
||||||
// Port occupied / environment issues
|
|
||||||
if (/port.*occup|9473|端口|舊進程|旧进程/i.test(normalized)) {
|
const pathMatch = text.match(/(?:\/[^ | ||||||